@zibby/workflow-templates 0.3.0 → 0.4.2
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.
- package/browser-test-automation/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/browser-test-automation/package.json +1 -0
- package/code-analysis/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/code-analysis/nodes/generate-code-node.js +12 -2
- package/index.js +235 -0
- package/notify-lark/README.md +88 -0
- package/notify-lark/graph.mjs +43 -0
- package/notify-lark/icon.png +0 -0
- package/notify-lark/nodes/notify-lark-node.js +303 -0
- package/notify-lark/package.json +18 -0
- package/notify-lark/state.js +85 -0
- package/notify-notion/README.md +71 -0
- package/notify-notion/graph.mjs +64 -0
- package/notify-notion/icon.png +0 -0
- package/notify-notion/nodes/notify-notion-node.js +342 -0
- package/notify-notion/package.json +19 -0
- package/notify-notion/state.js +110 -0
- package/notify-slack/README.md +94 -0
- package/notify-slack/graph.mjs +51 -0
- package/notify-slack/icon.png +0 -0
- package/notify-slack/nodes/notify-slack-node.js +268 -0
- package/notify-slack/package.json +18 -0
- package/notify-slack/state.js +112 -0
- package/package.json +17 -3
- package/sentry-triage/graph.mjs +81 -0
- package/sentry-triage/icon.png +0 -0
- package/sentry-triage/nodes/classify-node.js +38 -0
- package/sentry-triage/nodes/dispatch-alerts-node.js +191 -0
- package/sentry-triage/nodes/fetch-issues-node.js +52 -0
- package/sentry-triage/nodes/filter-noise-node.js +112 -0
- package/sentry-triage/package.json +18 -0
- package/sentry-triage/prompts/classify.md +76 -0
- package/sentry-triage/prompts/fetch-issues.md +66 -0
- package/sentry-triage/state.js +134 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-notion node — deterministic, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Posts a single page (or appends blocks to an existing page) to a
|
|
5
|
+
* Notion workspace via the official Notion REST API. Same design
|
|
6
|
+
* philosophy as notify-slack and notify-lark: parent makes all the
|
|
7
|
+
* decisions (severity, title, body, where), this node just renders
|
|
8
|
+
* the blocks and dispatches the API call.
|
|
9
|
+
*
|
|
10
|
+
* Auth: pulls the Notion OAuth bearer token via @zibby/core's
|
|
11
|
+
* `resolveIntegrationToken('notion')`. Backend's notion handler
|
|
12
|
+
* returns `{ token, workspaceId }` — we only need the token, but
|
|
13
|
+
* tests / debugging consume the workspaceId too.
|
|
14
|
+
*
|
|
15
|
+
* Two write paths:
|
|
16
|
+
* - databaseId → POST /v1/pages (creates a new page in a database)
|
|
17
|
+
* - pageId → PATCH /v1/blocks/{pageId}/children
|
|
18
|
+
* (appends blocks to an existing page)
|
|
19
|
+
*
|
|
20
|
+
* Mutual exclusion is enforced at runtime — see state.js for why we
|
|
21
|
+
* don't do it in the zod schema.
|
|
22
|
+
*
|
|
23
|
+
* Failure modes (no retry — sub-graph caller handles retries):
|
|
24
|
+
* - Token missing / wrong scope → resolveIntegrationToken throws
|
|
25
|
+
* "notion is not connected".
|
|
26
|
+
* - 401 → Notion rejected token (token revoked or wrong workspace).
|
|
27
|
+
* - 404 → page/database not found (or bot not added to it).
|
|
28
|
+
* - 429 → Notion rate-limited (rare; caller should backoff + retry).
|
|
29
|
+
* - 5xx → typed error with .status set; caller can re-dispatch.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { z } from 'zod';
|
|
33
|
+
import { SKILLS } from '@zibby/core';
|
|
34
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
35
|
+
// Universal renderer — see notify-slack-node.js / notify-lark-node.js
|
|
36
|
+
// for the design note. When state.report is present the legacy
|
|
37
|
+
// buildNotionPayload path is bypassed entirely.
|
|
38
|
+
import { reportToNotionBlocks } from '@zibby/skills/report';
|
|
39
|
+
|
|
40
|
+
// Notion REST API root. Hardcoded — there's no on-prem variant and
|
|
41
|
+
// the backend doesn't return a host.
|
|
42
|
+
const NOTION_API_BASE = 'https://api.notion.com/v1';
|
|
43
|
+
|
|
44
|
+
// Notion-Version pin. As of 2026-05 this is the latest stable version.
|
|
45
|
+
// Notion's API is versioned at the request level (header), not the
|
|
46
|
+
// URL path, so we MUST send this to get a deterministic schema.
|
|
47
|
+
const NOTION_VERSION = '2022-06-28';
|
|
48
|
+
|
|
49
|
+
const SEVERITY_ICON = Object.freeze({
|
|
50
|
+
low: '⚪',
|
|
51
|
+
medium: '🟡',
|
|
52
|
+
high: '🟠',
|
|
53
|
+
critical: '🚨',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const NotifyNotionOutputSchema = z.object({
|
|
57
|
+
delivered: z.boolean().describe('true if the Notion API request returned 2xx'),
|
|
58
|
+
pageId: z.string().describe('ID of the page that was created (databaseId branch) or appended to (pageId branch)'),
|
|
59
|
+
pageUrl: z.string().optional().describe('Notion URL for the page — set only on the create branch (Notion returns it in the response). Empty on append.'),
|
|
60
|
+
blocksCount: z.number().int().optional().describe('Block count posted — diagnostic only.'),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the Notion page-creation OR block-append request body from
|
|
65
|
+
* the input. Exposed for unit tests so we can pin the rendered shape
|
|
66
|
+
* without going through fetch.
|
|
67
|
+
*
|
|
68
|
+
* Returns one of two shapes:
|
|
69
|
+
* - `{ kind: 'page', url: '<api>/pages', body: {...} }`
|
|
70
|
+
* - `{ kind: 'append', url: '<api>/blocks/{pageId}/children', body: {...} }`
|
|
71
|
+
*
|
|
72
|
+
* The shape is determined by which of databaseId / pageId is supplied;
|
|
73
|
+
* exactly one must be present (the caller's runtime guard catches the
|
|
74
|
+
* "neither/both" case before this is called).
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} input
|
|
77
|
+
* @returns {{ kind: 'page'|'append', url: string, body: Object, blocksCount: number, title: string, icon?: string }}
|
|
78
|
+
*/
|
|
79
|
+
export function buildNotionPayload(input) {
|
|
80
|
+
const {
|
|
81
|
+
severity = 'medium',
|
|
82
|
+
title: inputTitle,
|
|
83
|
+
body,
|
|
84
|
+
databaseId,
|
|
85
|
+
pageId,
|
|
86
|
+
sentryLink,
|
|
87
|
+
affectedUsers,
|
|
88
|
+
events,
|
|
89
|
+
release,
|
|
90
|
+
firstSeen,
|
|
91
|
+
codeSnippet,
|
|
92
|
+
actionUrl,
|
|
93
|
+
actionLabel,
|
|
94
|
+
mentions,
|
|
95
|
+
report,
|
|
96
|
+
} = input;
|
|
97
|
+
|
|
98
|
+
// ── Rich-mode: defer entirely to @zibby/skills's renderer ─────────
|
|
99
|
+
if (report && typeof report === 'object') {
|
|
100
|
+
const rendered = reportToNotionBlocks(report);
|
|
101
|
+
if (databaseId) {
|
|
102
|
+
const pageBody = {
|
|
103
|
+
parent: { database_id: databaseId },
|
|
104
|
+
properties: {
|
|
105
|
+
Name: {
|
|
106
|
+
title: [{ text: { content: rendered.title } }],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
children: rendered.blocks,
|
|
110
|
+
};
|
|
111
|
+
if (rendered.icon) {
|
|
112
|
+
pageBody.icon = { type: 'emoji', emoji: rendered.icon };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
kind: 'page',
|
|
116
|
+
url: `${NOTION_API_BASE}/pages`,
|
|
117
|
+
body: pageBody,
|
|
118
|
+
blocksCount: rendered.blocks.length,
|
|
119
|
+
title: rendered.title,
|
|
120
|
+
icon: rendered.icon,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// pageId branch — append children (no title/icon — those are page
|
|
124
|
+
// properties and can't be updated via the children endpoint).
|
|
125
|
+
return {
|
|
126
|
+
kind: 'append',
|
|
127
|
+
url: `${NOTION_API_BASE}/blocks/${encodeURIComponent(pageId)}/children`,
|
|
128
|
+
body: { children: rendered.blocks },
|
|
129
|
+
blocksCount: rendered.blocks.length,
|
|
130
|
+
title: rendered.title,
|
|
131
|
+
icon: rendered.icon,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Legacy-mode: render severity/title/body into a simple Notion page ─
|
|
136
|
+
const titleText = `${severity.toUpperCase()}: ${inputTitle || ''}`.trim();
|
|
137
|
+
const icon = SEVERITY_ICON[severity] || SEVERITY_ICON.medium;
|
|
138
|
+
const children = [];
|
|
139
|
+
|
|
140
|
+
if (body && body.trim().length > 0) {
|
|
141
|
+
children.push({
|
|
142
|
+
object: 'block',
|
|
143
|
+
type: 'paragraph',
|
|
144
|
+
paragraph: { rich_text: [{ type: 'text', text: { content: body.slice(0, 2000) } }] },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sentry-flavored metadata — rendered as a bulleted list when at
|
|
149
|
+
// least one field is present. Mirrors notify-slack's "fields" block.
|
|
150
|
+
const metaLines = [];
|
|
151
|
+
if (typeof affectedUsers === 'number') metaLines.push(`Users affected: ${affectedUsers}`);
|
|
152
|
+
if (typeof events === 'number') metaLines.push(`Events: ${events}`);
|
|
153
|
+
if (release) metaLines.push(`Release: ${release}`);
|
|
154
|
+
if (firstSeen) metaLines.push(`First seen: ${firstSeen}`);
|
|
155
|
+
for (const line of metaLines) {
|
|
156
|
+
children.push({
|
|
157
|
+
object: 'block',
|
|
158
|
+
type: 'bulleted_list_item',
|
|
159
|
+
bulleted_list_item: { rich_text: [{ type: 'text', text: { content: line } }] },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (codeSnippet) {
|
|
164
|
+
children.push({
|
|
165
|
+
object: 'block',
|
|
166
|
+
type: 'code',
|
|
167
|
+
code: {
|
|
168
|
+
rich_text: [{ type: 'text', text: { content: codeSnippet.slice(0, 2000) } }],
|
|
169
|
+
language: 'plain text',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Action links — embeds (Notion's closest equivalent to buttons).
|
|
175
|
+
if (sentryLink) {
|
|
176
|
+
children.push({
|
|
177
|
+
object: 'block',
|
|
178
|
+
type: 'embed',
|
|
179
|
+
embed: { url: sentryLink },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (actionUrl && actionLabel) {
|
|
183
|
+
children.push({
|
|
184
|
+
object: 'block',
|
|
185
|
+
type: 'embed',
|
|
186
|
+
embed: { url: actionUrl },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Mentions — plain-text bulleted list at the bottom.
|
|
191
|
+
if (Array.isArray(mentions) && mentions.length > 0) {
|
|
192
|
+
children.push({
|
|
193
|
+
object: 'block',
|
|
194
|
+
type: 'heading_3',
|
|
195
|
+
heading_3: { rich_text: [{ type: 'text', text: { content: 'Notify' } }] },
|
|
196
|
+
});
|
|
197
|
+
for (const m of mentions) {
|
|
198
|
+
children.push({
|
|
199
|
+
object: 'block',
|
|
200
|
+
type: 'bulleted_list_item',
|
|
201
|
+
bulleted_list_item: { rich_text: [{ type: 'text', text: { content: m } }] },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (databaseId) {
|
|
207
|
+
return {
|
|
208
|
+
kind: 'page',
|
|
209
|
+
url: `${NOTION_API_BASE}/pages`,
|
|
210
|
+
body: {
|
|
211
|
+
parent: { database_id: databaseId },
|
|
212
|
+
icon: { type: 'emoji', emoji: icon },
|
|
213
|
+
properties: {
|
|
214
|
+
Name: {
|
|
215
|
+
title: [{ text: { content: titleText.slice(0, 200) } }],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
children,
|
|
219
|
+
},
|
|
220
|
+
blocksCount: children.length,
|
|
221
|
+
title: titleText,
|
|
222
|
+
icon,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// pageId branch — append children only.
|
|
226
|
+
return {
|
|
227
|
+
kind: 'append',
|
|
228
|
+
url: `${NOTION_API_BASE}/blocks/${encodeURIComponent(pageId)}/children`,
|
|
229
|
+
body: { children },
|
|
230
|
+
blocksCount: children.length,
|
|
231
|
+
title: titleText,
|
|
232
|
+
icon,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Post the request to Notion. Exposed for unit tests so we can stub
|
|
238
|
+
* fetch + assert payload shape without going through token resolution.
|
|
239
|
+
*
|
|
240
|
+
* Notion's API uses POST for page creation and PATCH for children
|
|
241
|
+
* append — the `kind` field of the buildNotionPayload result tells us
|
|
242
|
+
* which method to use.
|
|
243
|
+
*/
|
|
244
|
+
export async function postToNotion({ token, kind, url, body }) {
|
|
245
|
+
const method = kind === 'append' ? 'PATCH' : 'POST';
|
|
246
|
+
const res = await fetch(url, {
|
|
247
|
+
method,
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
'Notion-Version': NOTION_VERSION,
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify(body),
|
|
254
|
+
});
|
|
255
|
+
// Notion returns JSON for both success and error responses; the
|
|
256
|
+
// status code distinguishes. We parse defensively because a network
|
|
257
|
+
// proxy might return non-JSON on a 5xx.
|
|
258
|
+
const data = await res.json().catch(() => ({}));
|
|
259
|
+
if (res.status < 200 || res.status >= 300) {
|
|
260
|
+
// Map common status codes to friendly messages. The mapping below
|
|
261
|
+
// mirrors the Notion API docs:
|
|
262
|
+
// 401 unauthorized — token revoked / wrong workspace
|
|
263
|
+
// 404 object_not_found — page/database not found OR bot not added
|
|
264
|
+
// 429 rate_limited
|
|
265
|
+
let msg;
|
|
266
|
+
if (res.status === 401) msg = 'Notion rejected token';
|
|
267
|
+
else if (res.status === 404) msg = 'Notion page/database not found';
|
|
268
|
+
else if (res.status === 429) msg = 'Notion rate-limited';
|
|
269
|
+
else msg = data.message || `http ${res.status}`;
|
|
270
|
+
const err = new Error(`Notion API failed: ${msg}`);
|
|
271
|
+
err.code = data.code || `http_${res.status}`;
|
|
272
|
+
err.status = res.status;
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
return data;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const notifyNotionNode = {
|
|
279
|
+
name: 'notify_notion',
|
|
280
|
+
// Declares Notion as a hard requirement for marketplace gating —
|
|
281
|
+
// same pattern as notify-slack / notify-lark. See those for the
|
|
282
|
+
// rationale. The MCP notion tool isn't actually invoked here
|
|
283
|
+
// (custom-execute path); the declaration wires the gate only.
|
|
284
|
+
skills: [SKILLS.NOTION],
|
|
285
|
+
outputSchema: NotifyNotionOutputSchema,
|
|
286
|
+
// 20s — Notion's page-create can be slower than Slack/Lark when
|
|
287
|
+
// appending many children (each child block adds ~10ms server-side).
|
|
288
|
+
timeout: 20 * 1000,
|
|
289
|
+
execute: async (context) => {
|
|
290
|
+
// Custom-execute gets either the merged state object or the new
|
|
291
|
+
// {state, agent, ...} context wrapper. Normalize to a plain state.
|
|
292
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
293
|
+
? context.state.getAll()
|
|
294
|
+
: context;
|
|
295
|
+
|
|
296
|
+
const { databaseId, pageId } = state;
|
|
297
|
+
// Mutual exclusion: exactly one destination must be supplied. We
|
|
298
|
+
// check both directions explicitly so the error message points at
|
|
299
|
+
// the actual misconfiguration (rather than "Invalid input" from a
|
|
300
|
+
// zod refinement).
|
|
301
|
+
if (!databaseId && !pageId) {
|
|
302
|
+
throw new Error('notify-notion: must supply exactly one of input.databaseId or input.pageId');
|
|
303
|
+
}
|
|
304
|
+
if (databaseId && pageId) {
|
|
305
|
+
throw new Error('notify-notion: databaseId and pageId are mutually exclusive — pick one');
|
|
306
|
+
}
|
|
307
|
+
if (!state.report && !state.title) {
|
|
308
|
+
throw new Error('notify-notion: input.title is required when input.report is absent');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Resolve the bot token. The cache inside resolveIntegrationToken
|
|
312
|
+
// means this is a single HTTP RTT the first time per process; later
|
|
313
|
+
// dispatches in the same Fargate task are zero-cost.
|
|
314
|
+
const { token } = await resolveIntegrationToken('notion');
|
|
315
|
+
|
|
316
|
+
const payload = buildNotionPayload(state);
|
|
317
|
+
const response = await postToNotion({
|
|
318
|
+
token,
|
|
319
|
+
kind: payload.kind,
|
|
320
|
+
url: payload.url,
|
|
321
|
+
body: payload.body,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// For the create-page branch, Notion returns the new page object
|
|
325
|
+
// (with `id` + `url`). For the append-children branch, the
|
|
326
|
+
// response is `{ object: 'list', results: [...blocks] }` and we
|
|
327
|
+
// already know the page id (the caller supplied it).
|
|
328
|
+
const resultPageId = payload.kind === 'page'
|
|
329
|
+
? response.id
|
|
330
|
+
: pageId;
|
|
331
|
+
const resultPageUrl = payload.kind === 'page'
|
|
332
|
+
? response.url
|
|
333
|
+
: undefined;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
delivered: true,
|
|
337
|
+
pageId: resultPageId,
|
|
338
|
+
pageUrl: resultPageUrl,
|
|
339
|
+
blocksCount: payload.blocksCount,
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notify-notion",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Post a structured page (or append blocks) to Notion — child workflow, dispatched by parent workflows via sub-graph.",
|
|
7
|
+
"main": "graph.mjs",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zibby/core": "^0.5.1",
|
|
13
|
+
"@zibby/skills": "^0.1.25",
|
|
14
|
+
"zod": "^3.23.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"vitest": "^2.1.5"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-notion — three-schema state model.
|
|
3
|
+
*
|
|
4
|
+
* Posts to Notion as either:
|
|
5
|
+
* - a NEW page inside a database (`databaseId`), or
|
|
6
|
+
* - APPENDED blocks on an existing page (`pageId`)
|
|
7
|
+
* EXACTLY ONE of these must be supplied. The execute path enforces
|
|
8
|
+
* mutual exclusion at runtime — we keep both fields optional at the
|
|
9
|
+
* schema level so the wire format degrades gracefully (e.g. when a
|
|
10
|
+
* parent workflow forgets to set one, the error message is clearer
|
|
11
|
+
* than a zod refinement failure on an unfamiliar field name).
|
|
12
|
+
*
|
|
13
|
+
* Mirrors notify-slack / notify-lark's provider-neutral envelope so a
|
|
14
|
+
* parent can fan-out the same alert to all three notifiers with a
|
|
15
|
+
* single `input: (state) => ({...})` block (swapping the target field
|
|
16
|
+
* per provider: `channel`, `receiveId`, or `pageId`/`databaseId`).
|
|
17
|
+
*
|
|
18
|
+
* Three schemas:
|
|
19
|
+
* - notifyNotionInputSchema — payload from the parent's
|
|
20
|
+
* `dispatchSubgraph('notify-notion', { input: … })`
|
|
21
|
+
* - notifyNotionContextSchema — runner-injected fields + node outputs
|
|
22
|
+
* - notifyNotionStateSchema — merge of the two (tests + tooling)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
|
|
27
|
+
export const SEVERITIES = /** @type {const} */ (['low', 'medium', 'high', 'critical']);
|
|
28
|
+
|
|
29
|
+
export const notifyNotionInputSchema = z.object({
|
|
30
|
+
severity: z.enum(SEVERITIES)
|
|
31
|
+
.default('medium')
|
|
32
|
+
.describe('Alert severity. Drives legacy-mode page-icon emoji + decoration.'),
|
|
33
|
+
|
|
34
|
+
// Required in legacy-mode (severity/title/body alerts); rich-mode
|
|
35
|
+
// (when `report` is set) sources the title from `report.title`. The
|
|
36
|
+
// execute path enforces "at least one source of title" at runtime —
|
|
37
|
+
// see notify-slack/state.js for the symmetric design.
|
|
38
|
+
title: z.string().min(1).max(300).optional()
|
|
39
|
+
.describe('Page title. Required when `report` is absent (rich-mode sources from report.title).'),
|
|
40
|
+
|
|
41
|
+
body: z.string().max(3000).optional()
|
|
42
|
+
.describe('Body text. Rendered as a paragraph block in the page.'),
|
|
43
|
+
|
|
44
|
+
// ── Destination — EXACTLY ONE must be set ─────────────────────────
|
|
45
|
+
// We don't enforce mutual exclusion via zod refinement because the
|
|
46
|
+
// resulting error message ("Invalid input") is less helpful than the
|
|
47
|
+
// execute-path's typed runtime checks ("notify-notion: must supply
|
|
48
|
+
// exactly one of databaseId or pageId"). Schema-level validation is
|
|
49
|
+
// intentionally permissive on the wire.
|
|
50
|
+
databaseId: z.string().min(1).max(100).optional()
|
|
51
|
+
.describe('Notion database id to create a new page in (e.g. "abc123def...32hex"). Mutually exclusive with pageId.'),
|
|
52
|
+
|
|
53
|
+
pageId: z.string().min(1).max(100).optional()
|
|
54
|
+
.describe('Existing Notion page id to append blocks to. Mutually exclusive with databaseId.'),
|
|
55
|
+
|
|
56
|
+
// ── Sentry-flavored optional context (legacy-mode) ────────────────
|
|
57
|
+
// Same fields as notify-slack so a parent can dispatch a single
|
|
58
|
+
// payload to all three notifiers. notify-notion renders these as
|
|
59
|
+
// bulleted-list items in the page body when present.
|
|
60
|
+
sentryLink: z.string().url().optional()
|
|
61
|
+
.describe('Sentry issue URL — rendered as an embed/link in the page.'),
|
|
62
|
+
affectedUsers: z.number().int().min(0).optional(),
|
|
63
|
+
events: z.number().int().min(0).optional(),
|
|
64
|
+
release: z.string().max(120).optional(),
|
|
65
|
+
firstSeen: z.string().optional(),
|
|
66
|
+
codeSnippet: z.string().max(2000).optional()
|
|
67
|
+
.describe('Optional code snippet — rendered as a Notion `code` block.'),
|
|
68
|
+
|
|
69
|
+
actionUrl: z.string().url().optional(),
|
|
70
|
+
actionLabel: z.string().max(40).optional(),
|
|
71
|
+
|
|
72
|
+
// Mentions — Notion's mention syntax (`@user`) requires user UUIDs
|
|
73
|
+
// that don't survive serialization to a generic alert payload, so we
|
|
74
|
+
// pass through caller-supplied strings as plain text in a bulleted
|
|
75
|
+
// list. Useful for "cc'ing" team names without requiring the caller
|
|
76
|
+
// to resolve user IDs upfront.
|
|
77
|
+
mentions: z.array(z.string().max(200))
|
|
78
|
+
.max(20)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe('Plain-text mentions, rendered as a "Notify" bulleted list at the bottom of the page.'),
|
|
81
|
+
|
|
82
|
+
idempotencyKey: z.string().max(128).optional(),
|
|
83
|
+
|
|
84
|
+
// ── Rich-report mode ──────────────────────────────────────────────
|
|
85
|
+
// When `report` is present, the node renders the report-object via
|
|
86
|
+
// reportToNotionBlocks() and IGNORES severity/title/body/sentryLink/etc.
|
|
87
|
+
// (those legacy fields are exclusively for the simple-alert path).
|
|
88
|
+
//
|
|
89
|
+
// The contract lives in @zibby/skills's reportObjectSchema. We
|
|
90
|
+
// declare it as record(any) on the wire so old runtimes that don't
|
|
91
|
+
// know about the field don't fail validation, and the actual
|
|
92
|
+
// structure is validated by reportToNotionBlocks at render time.
|
|
93
|
+
// New consumers should import reportObjectSchema from @zibby/skills
|
|
94
|
+
// for tighter typing.
|
|
95
|
+
report: z.record(z.any()).optional()
|
|
96
|
+
.describe('Rich report-object (see @zibby/skills/report). When set, supersedes severity/title/body — the node renders a full Notion blocks payload.'),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const notifyNotionContextSchema = z.object({
|
|
100
|
+
notify_notion: z.object({
|
|
101
|
+
delivered: z.boolean(),
|
|
102
|
+
pageId: z.string(),
|
|
103
|
+
pageUrl: z.string().optional(),
|
|
104
|
+
blocksCount: z.number().int().optional(),
|
|
105
|
+
}).optional()
|
|
106
|
+
.describe('Output of the notify_notion node — set after dispatch completes.'),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const notifyNotionStateSchema =
|
|
110
|
+
notifyNotionInputSchema.merge(notifyNotionContextSchema);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# notify-slack
|
|
2
|
+
|
|
3
|
+
A reusable **child workflow** that posts a structured Block Kit alert to a Slack channel.
|
|
4
|
+
|
|
5
|
+
Designed to be dispatched via sub-graph from any parent workflow — most commonly the Sentry templates (`sentry-triage`, `sentry-autofix`, `sentry-incident`), but works anywhere you want a structured Slack message: cron summaries, deploy notifications, PR review pings, etc.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Takes a provider-neutral payload (severity, title, body, channel, optional Sentry-flavored fields)
|
|
10
|
+
- Builds a Slack Block Kit message with severity-coded color + emoji + action buttons
|
|
11
|
+
- Posts via `chat.postMessage` to the channel you specify
|
|
12
|
+
- Returns the message timestamp so the parent can thread follow-ups
|
|
13
|
+
|
|
14
|
+
No LLM call — single deterministic API request, typically <500ms.
|
|
15
|
+
|
|
16
|
+
## Dispatch shape (parent workflow)
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { WorkflowGraph, SKILLS } from '@zibby/core';
|
|
20
|
+
|
|
21
|
+
const graph = new WorkflowGraph();
|
|
22
|
+
|
|
23
|
+
graph.addNode('alert', {
|
|
24
|
+
workflow: 'notify-slack',
|
|
25
|
+
async: false,
|
|
26
|
+
input: (state) => ({
|
|
27
|
+
severity: 'critical',
|
|
28
|
+
title: 'Checkout: TypeError on session.user.id',
|
|
29
|
+
body: '*12 users* affected in the last 1h. Likely regression from `1.42.0`.',
|
|
30
|
+
channel: '#sentry-alerts',
|
|
31
|
+
sentryLink: 'https://sentry.io/.../1234567890/',
|
|
32
|
+
affectedUsers: 12,
|
|
33
|
+
events: 47,
|
|
34
|
+
release: '1.42.0',
|
|
35
|
+
firstSeen: '8 min ago',
|
|
36
|
+
codeSnippet: 'src/handlers/checkout.ts:142\nconst userId = session.user.id;',
|
|
37
|
+
mentions: ['<!subteam^S0BACKEND>'],
|
|
38
|
+
}),
|
|
39
|
+
output: 'notify_slack.messageTs', // capture the message ts for thread replies
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Inputs
|
|
44
|
+
|
|
45
|
+
| Field | Required | Description |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `severity` | yes | `low \| medium \| high \| critical` — drives color + emoji |
|
|
48
|
+
| `title` | yes | Headline (max 300 chars) |
|
|
49
|
+
| `channel` | yes | Slack channel id (`C012345`) or `#name` |
|
|
50
|
+
| `body` | no | mrkdwn body (max 3000 chars) |
|
|
51
|
+
| `sentryLink` | no | Renders an "Open in Sentry" primary button |
|
|
52
|
+
| `affectedUsers` | no | Renders in the metadata grid |
|
|
53
|
+
| `events` | no | " |
|
|
54
|
+
| `release` | no | " |
|
|
55
|
+
| `firstSeen` | no | " |
|
|
56
|
+
| `codeSnippet` | no | Renders as a fenced code block |
|
|
57
|
+
| `actionUrl` + `actionLabel` | no | Secondary button (e.g. "View PR") |
|
|
58
|
+
| `mentions` | no | Array of Slack mention strings appended in the context block |
|
|
59
|
+
|
|
60
|
+
## Output
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
{ delivered: true, channel: 'C012345', messageTs: '1716109330.123456', blocksCount: 5 }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The `messageTs` is what you pass back as `thread_ts` if you want to reply in-thread later (use case: incident-commander posting periodic updates).
|
|
67
|
+
|
|
68
|
+
## Prerequisites
|
|
69
|
+
|
|
70
|
+
The deploying project must have the **Slack** integration connected. Bot needs:
|
|
71
|
+
- `chat:write` (post messages)
|
|
72
|
+
- `chat:write.public` (optional — post to public channels the bot isn't a member of)
|
|
73
|
+
|
|
74
|
+
## Severity → color
|
|
75
|
+
|
|
76
|
+
| Severity | Hex | Emoji |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| low | `#7f8c8d` | ⚪ |
|
|
79
|
+
| medium | `#f1c40f` | 🟡 |
|
|
80
|
+
| high | `#e67e22` | 🟠 |
|
|
81
|
+
| critical | `#c0392b` | 🚨 |
|
|
82
|
+
|
|
83
|
+
## Tests
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd packages/workflow-templates/notify-slack
|
|
87
|
+
npm test
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Tests cover:
|
|
91
|
+
- Block-Kit rendering for each severity
|
|
92
|
+
- Conditional sections (omits fields/code/actions when input missing)
|
|
93
|
+
- Slack API contract (mocked `fetch`)
|
|
94
|
+
- Error mapping (channel_not_found, not_in_channel, network blip)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-slack — single-node child workflow.
|
|
3
|
+
*
|
|
4
|
+
* Designed to be dispatched as a sub-graph from parent workflows
|
|
5
|
+
* (sentry-triage, sentry-autofix, sentry-incident, etc.) via
|
|
6
|
+
*
|
|
7
|
+
* g.addNode('notify', { workflow: 'notify-slack',
|
|
8
|
+
* input: (state) => ({
|
|
9
|
+
* severity: 'critical',
|
|
10
|
+
* title: 'Checkout: TypeError',
|
|
11
|
+
* body: '12 users affected in last 1h',
|
|
12
|
+
* channel: '#sentry-alerts',
|
|
13
|
+
* sentryLink: 'https://sentry.io/.../1234567890/',
|
|
14
|
+
* affectedUsers: 12,
|
|
15
|
+
* }),
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* Returns `{ delivered, channel, messageTs }` — the parent typically
|
|
19
|
+
* stores `messageTs` if it wants to post follow-up replies as thread
|
|
20
|
+
* messages later (e.g. incident-commander posting progress updates).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
|
|
24
|
+
import { notifySlackNode } from './nodes/notify-slack-node.js';
|
|
25
|
+
import {
|
|
26
|
+
notifySlackInputSchema,
|
|
27
|
+
notifySlackContextSchema,
|
|
28
|
+
} from './state.js';
|
|
29
|
+
|
|
30
|
+
export class NotifySlackAgent extends WorkflowAgent {
|
|
31
|
+
buildGraph() {
|
|
32
|
+
const graph = new WorkflowGraph();
|
|
33
|
+
graph
|
|
34
|
+
.setInputSchema(notifySlackInputSchema)
|
|
35
|
+
.setContextSchema(notifySlackContextSchema);
|
|
36
|
+
|
|
37
|
+
graph.addNode('notify_slack', notifySlackNode);
|
|
38
|
+
graph.setEntryPoint('notify_slack');
|
|
39
|
+
graph.addEdge('notify_slack', 'END');
|
|
40
|
+
|
|
41
|
+
return graph;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async onComplete(result) {
|
|
45
|
+
const delivered = !!result?.state?.notify_slack?.delivered;
|
|
46
|
+
const ts = result?.state?.notify_slack?.messageTs || '?';
|
|
47
|
+
console.log(`[notify-slack] ${delivered ? 'delivered' : 'failed'} (ts=${ts})`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default NotifySlackAgent;
|
|
Binary file
|