@zibby/workflow-templates 0.2.1 → 0.4.1
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/graph.js +5 -4
- package/code-analysis/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/code-analysis/nodes/analyze-ticket-node.js +9 -2
- package/code-analysis/nodes/generate-code-node.js +27 -11
- package/code-analysis/nodes/setup-node.js +50 -130
- package/code-analysis/nodes/utils/get-repo-path.js +32 -0
- package/code-analysis/prompts/setup.md +71 -0
- package/code-analysis/state.js +16 -0
- package/generate-test-cases/graph.mjs +11 -1
- package/generate-test-cases/nodes/setup-node.js +32 -130
- package/generate-test-cases/prompts/setup.md +50 -0
- package/generate-test-cases/state.js +12 -0
- package/index.js +136 -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 +290 -0
- package/notify-lark/package.json +18 -0
- package/notify-lark/state.js +75 -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 +238 -0
- package/notify-slack/package.json +18 -0
- package/notify-slack/state.js +93 -0
- package/package.json +12 -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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-lark node — deterministic, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Sends a Lark Interactive Card via /open-apis/im/v1/messages. Same
|
|
5
|
+
* design philosophy as notify-slack: parent makes all the decisions
|
|
6
|
+
* (severity, title, body, who to mention), this node just renders the
|
|
7
|
+
* card and POSTs it.
|
|
8
|
+
*
|
|
9
|
+
* Auth differs from Slack:
|
|
10
|
+
* - resolveIntegrationToken('lark') returns { appId, appSecret, host }
|
|
11
|
+
* not a ready-to-use bearer token.
|
|
12
|
+
* - We exchange app credentials for a `tenant_access_token` via
|
|
13
|
+
* /open-apis/auth/v3/tenant_access_token/internal, then use that as
|
|
14
|
+
* Bearer auth on the message send. Same flow @zibby/skills/lark.js
|
|
15
|
+
* uses; we reimplement here so the node stays decoupled from the
|
|
16
|
+
* MCP-tool path (which only the LLM nodes use).
|
|
17
|
+
*
|
|
18
|
+
* The token has ~2h TTL — we cache per-process so a parent dispatching
|
|
19
|
+
* many alerts only pays the auth round-trip once.
|
|
20
|
+
*
|
|
21
|
+
* Severity → Lark header template (named themes, not hex):
|
|
22
|
+
* low → grey
|
|
23
|
+
* medium → yellow
|
|
24
|
+
* high → orange
|
|
25
|
+
* critical → red
|
|
26
|
+
*
|
|
27
|
+
* The Lark API quirk: `content` on the message body must be a
|
|
28
|
+
* JSON-encoded STRING (not the object itself). Easy to miss.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { SKILLS } from '@zibby/core';
|
|
33
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
34
|
+
|
|
35
|
+
const SEVERITY_TEMPLATE = Object.freeze({
|
|
36
|
+
low: 'grey',
|
|
37
|
+
medium: 'yellow',
|
|
38
|
+
high: 'orange',
|
|
39
|
+
critical: 'red',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const SEVERITY_EMOJI = Object.freeze({
|
|
43
|
+
low: '⚪',
|
|
44
|
+
medium: '🟡',
|
|
45
|
+
high: '🟠',
|
|
46
|
+
critical: '🚨',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const NotifyLarkOutputSchema = z.object({
|
|
50
|
+
delivered: z.boolean(),
|
|
51
|
+
receiveId: z.string(),
|
|
52
|
+
receiveIdType: z.string(),
|
|
53
|
+
messageId: z.string().optional(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the Lark Interactive Card body. Returns the card OBJECT (not
|
|
58
|
+
* the JSON-stringified form the API expects — callers stringify it).
|
|
59
|
+
* Exposed for unit tests so we can pin the rendered shape.
|
|
60
|
+
*/
|
|
61
|
+
export function buildLarkCard(input) {
|
|
62
|
+
const {
|
|
63
|
+
severity = 'medium',
|
|
64
|
+
title,
|
|
65
|
+
body,
|
|
66
|
+
sentryLink,
|
|
67
|
+
affectedUsers,
|
|
68
|
+
events,
|
|
69
|
+
release,
|
|
70
|
+
firstSeen,
|
|
71
|
+
codeSnippet,
|
|
72
|
+
actionUrl,
|
|
73
|
+
actionLabel,
|
|
74
|
+
mentions,
|
|
75
|
+
} = input;
|
|
76
|
+
|
|
77
|
+
const elements = [];
|
|
78
|
+
|
|
79
|
+
// Metadata grid — 2-column layout via `is_short: true` on each field.
|
|
80
|
+
const fields = [];
|
|
81
|
+
if (typeof affectedUsers === 'number') {
|
|
82
|
+
fields.push({
|
|
83
|
+
is_short: true,
|
|
84
|
+
text: { tag: 'lark_md', content: `**Users affected**\n${affectedUsers}` },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (typeof events === 'number') {
|
|
88
|
+
fields.push({
|
|
89
|
+
is_short: true,
|
|
90
|
+
text: { tag: 'lark_md', content: `**Events**\n${events}` },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (release) {
|
|
94
|
+
fields.push({
|
|
95
|
+
is_short: true,
|
|
96
|
+
text: { tag: 'lark_md', content: `**Release**\n\`${release}\`` },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (firstSeen) {
|
|
100
|
+
fields.push({
|
|
101
|
+
is_short: true,
|
|
102
|
+
text: { tag: 'lark_md', content: `**First seen**\n${firstSeen}` },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (fields.length > 0) {
|
|
106
|
+
elements.push({ tag: 'div', fields });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Body — rendered as lark_md (Lark's markdown variant).
|
|
110
|
+
if (body && body.trim().length > 0) {
|
|
111
|
+
elements.push({
|
|
112
|
+
tag: 'div',
|
|
113
|
+
text: { tag: 'lark_md', content: body },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Code snippet — fenced triple-backtick block.
|
|
118
|
+
if (codeSnippet) {
|
|
119
|
+
elements.push({
|
|
120
|
+
tag: 'div',
|
|
121
|
+
text: { tag: 'lark_md', content: '```\n' + codeSnippet + '\n```' },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Action buttons — same logic as Slack: primary "Open in Sentry"
|
|
126
|
+
// when sentryLink set, plus secondary actionUrl/Label pair if both
|
|
127
|
+
// supplied.
|
|
128
|
+
const actions = [];
|
|
129
|
+
if (sentryLink) {
|
|
130
|
+
actions.push({
|
|
131
|
+
tag: 'button',
|
|
132
|
+
text: { tag: 'plain_text', content: 'Open in Sentry' },
|
|
133
|
+
type: 'primary',
|
|
134
|
+
url: sentryLink,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (actionUrl && actionLabel) {
|
|
138
|
+
actions.push({
|
|
139
|
+
tag: 'button',
|
|
140
|
+
text: { tag: 'plain_text', content: actionLabel.slice(0, 40) },
|
|
141
|
+
type: 'default',
|
|
142
|
+
url: actionUrl,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (actions.length > 0) {
|
|
146
|
+
elements.push({ tag: 'action', actions });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Mentions — small-text note at bottom. Caller pre-formatted as
|
|
150
|
+
// `<at user_id="ou_xxx">@name</at>` strings.
|
|
151
|
+
if (Array.isArray(mentions) && mentions.length > 0) {
|
|
152
|
+
elements.push({
|
|
153
|
+
tag: 'note',
|
|
154
|
+
elements: [{ tag: 'lark_md', content: mentions.join(' ') }],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.medium;
|
|
159
|
+
const template = SEVERITY_TEMPLATE[severity] || SEVERITY_TEMPLATE.medium;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
config: { wide_screen_mode: true },
|
|
163
|
+
header: {
|
|
164
|
+
template,
|
|
165
|
+
title: {
|
|
166
|
+
tag: 'plain_text',
|
|
167
|
+
content: `${emoji} ${severity.toUpperCase()} ${title}`.slice(0, 150),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
elements,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Infer the Lark receive_id_type query param from the id prefix.
|
|
176
|
+
* Mirrors @zibby/skills/lark.js's inferReceiveIdType — duplicated to
|
|
177
|
+
* keep the node self-contained.
|
|
178
|
+
*/
|
|
179
|
+
export function inferReceiveIdType(id) {
|
|
180
|
+
if (!id || typeof id !== 'string') return 'chat_id';
|
|
181
|
+
if (id.startsWith('oc_')) return 'chat_id';
|
|
182
|
+
if (id.startsWith('ou_')) return 'open_id';
|
|
183
|
+
if (id.startsWith('on_')) return 'union_id';
|
|
184
|
+
if (id.startsWith('cli_')) return 'app_id';
|
|
185
|
+
if (id.includes('@')) return 'email';
|
|
186
|
+
return 'chat_id';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Per-process cache. Tenant access tokens last ~2h; we cache 100m to
|
|
190
|
+
// avoid the boundary case where a 119-min-old token is rejected mid-call.
|
|
191
|
+
const TOKEN_TTL_MS = 100 * 60 * 1000;
|
|
192
|
+
let _tenantTokenCache = null;
|
|
193
|
+
|
|
194
|
+
async function getTenantAccessToken() {
|
|
195
|
+
const { appId, appSecret, host } = await resolveIntegrationToken('lark');
|
|
196
|
+
if (
|
|
197
|
+
_tenantTokenCache
|
|
198
|
+
&& _tenantTokenCache.appId === appId
|
|
199
|
+
&& _tenantTokenCache.expiresAt > Date.now()
|
|
200
|
+
) {
|
|
201
|
+
return { token: _tenantTokenCache.token, host };
|
|
202
|
+
}
|
|
203
|
+
const url = `${host}/open-apis/auth/v3/tenant_access_token/internal`;
|
|
204
|
+
const res = await fetch(url, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
208
|
+
});
|
|
209
|
+
const data = await res.json().catch(() => ({}));
|
|
210
|
+
if (data.code !== 0) {
|
|
211
|
+
const e = new Error(`Lark tenant_access_token failed: ${data.msg || data.code}`);
|
|
212
|
+
e.code = data.code;
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
_tenantTokenCache = {
|
|
216
|
+
appId,
|
|
217
|
+
token: data.tenant_access_token,
|
|
218
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
219
|
+
};
|
|
220
|
+
return { token: data.tenant_access_token, host };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Test hook — resets the per-process token cache. */
|
|
224
|
+
export function _resetTokenCache() { _tenantTokenCache = null; }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send the Lark card. Exposed for unit tests so we can stub fetch +
|
|
228
|
+
* assert payload shape without going through token resolution.
|
|
229
|
+
*/
|
|
230
|
+
export async function postToLark({ token, host, receiveId, receiveIdType, card }) {
|
|
231
|
+
const url = `${host}/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`;
|
|
232
|
+
const res = await fetch(url, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${token}`,
|
|
236
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
receive_id: receiveId,
|
|
240
|
+
msg_type: 'interactive',
|
|
241
|
+
// Lark wants `content` as a JSON-encoded STRING, not an object.
|
|
242
|
+
content: JSON.stringify(card),
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
const data = await res.json().catch(() => ({}));
|
|
246
|
+
if (data.code !== 0) {
|
|
247
|
+
const e = new Error(`Lark send message failed: ${data.msg || `code ${data.code} / http ${res.status}`}`);
|
|
248
|
+
e.code = data.code || 'unknown';
|
|
249
|
+
e.status = res.status;
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
return { messageId: data.data?.message_id };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const notifyLarkNode = {
|
|
256
|
+
name: 'notify_lark',
|
|
257
|
+
// Declares Lark as a hard requirement for marketplace gating (same
|
|
258
|
+
// pattern as notify-slack — see that file's comment for rationale).
|
|
259
|
+
skills: [SKILLS.LARK],
|
|
260
|
+
outputSchema: NotifyLarkOutputSchema,
|
|
261
|
+
timeout: 15 * 1000,
|
|
262
|
+
execute: async (context) => {
|
|
263
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
264
|
+
? context.state.getAll()
|
|
265
|
+
: context;
|
|
266
|
+
|
|
267
|
+
const receiveId = state.receiveId;
|
|
268
|
+
if (!receiveId) {
|
|
269
|
+
throw new Error('notify-lark: input.receiveId is required');
|
|
270
|
+
}
|
|
271
|
+
const receiveIdType = inferReceiveIdType(receiveId);
|
|
272
|
+
|
|
273
|
+
const { token, host } = await getTenantAccessToken();
|
|
274
|
+
const card = buildLarkCard(state);
|
|
275
|
+
const { messageId } = await postToLark({
|
|
276
|
+
token,
|
|
277
|
+
host,
|
|
278
|
+
receiveId,
|
|
279
|
+
receiveIdType,
|
|
280
|
+
card,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
delivered: true,
|
|
285
|
+
receiveId,
|
|
286
|
+
receiveIdType,
|
|
287
|
+
messageId,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notify-lark",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Post a structured alert card to a Lark / Feishu chat — 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
|
+
"zod": "^3.23.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"vitest": "^2.1.5"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-lark — three-schema state model.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors notify-slack's input shape EXACTLY (provider-neutral fields)
|
|
5
|
+
* so a parent workflow that wants to fan-out alerts to both Slack and
|
|
6
|
+
* Lark can use the same `input: (state) => ({...})` block for both
|
|
7
|
+
* dispatchSubgraph calls. The notifier-specific bits (`receiveId` vs
|
|
8
|
+
* `channel`, mention syntax) are caller-supplied; we don't try to
|
|
9
|
+
* translate between providers.
|
|
10
|
+
*
|
|
11
|
+
* Lark differences vs Slack:
|
|
12
|
+
* - Target is `receiveId` (chat_id `oc_…`, open_id `ou_…`, or email)
|
|
13
|
+
* — Lark needs the right `receive_id_type` query param, inferred
|
|
14
|
+
* from the id prefix.
|
|
15
|
+
* - Color is a theme template name (`red`, `orange`, `yellow`, `grey`,
|
|
16
|
+
* `purple`, `green`), not a hex.
|
|
17
|
+
* - Mention syntax: `<at user_id="ou_xxx">name</at>` — caller passes
|
|
18
|
+
* these strings ready-formatted, same as Slack.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
export const SEVERITIES = /** @type {const} */ (['low', 'medium', 'high', 'critical']);
|
|
24
|
+
|
|
25
|
+
export const notifyLarkInputSchema = z.object({
|
|
26
|
+
severity: z.enum(SEVERITIES)
|
|
27
|
+
.default('medium')
|
|
28
|
+
.describe('Alert severity. Drives header color + mention strategy.'),
|
|
29
|
+
|
|
30
|
+
title: z.string().min(1).max(300)
|
|
31
|
+
.describe('Card header text (Lark caps at ~100 chars; we truncate to 150).'),
|
|
32
|
+
|
|
33
|
+
body: z.string().max(3000).optional()
|
|
34
|
+
.describe('Body text. Supports Lark lark_md (e.g. **bold**, `code`, [text](url)).'),
|
|
35
|
+
|
|
36
|
+
receiveId: z.string().min(1).max(120)
|
|
37
|
+
.describe(
|
|
38
|
+
'Lark target. Format determines receive_id_type automatically:\n' +
|
|
39
|
+
' oc_… → chat_id (group/DM)\n' +
|
|
40
|
+
' ou_… → open_id (user)\n' +
|
|
41
|
+
' on_… → union_id\n' +
|
|
42
|
+
' email → email',
|
|
43
|
+
),
|
|
44
|
+
|
|
45
|
+
// Sentry-flavored fields (same shape as notify-slack).
|
|
46
|
+
sentryLink: z.string().url().optional()
|
|
47
|
+
.describe('Sentry issue URL — adds an "Open in Sentry" primary button.'),
|
|
48
|
+
affectedUsers: z.number().int().min(0).optional(),
|
|
49
|
+
events: z.number().int().min(0).optional(),
|
|
50
|
+
release: z.string().max(120).optional(),
|
|
51
|
+
firstSeen: z.string().optional(),
|
|
52
|
+
codeSnippet: z.string().max(2000).optional(),
|
|
53
|
+
actionUrl: z.string().url().optional(),
|
|
54
|
+
actionLabel: z.string().max(40).optional(),
|
|
55
|
+
|
|
56
|
+
// Mentions — caller-supplied Lark <at> strings.
|
|
57
|
+
mentions: z.array(z.string().max(200))
|
|
58
|
+
.max(20)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Lark @-mentions, e.g. [\'<at user_id="ou_alice">@Alice</at>\'].'),
|
|
61
|
+
|
|
62
|
+
idempotencyKey: z.string().max(128).optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const notifyLarkContextSchema = z.object({
|
|
66
|
+
notify_lark: z.object({
|
|
67
|
+
delivered: z.boolean(),
|
|
68
|
+
receiveId: z.string(),
|
|
69
|
+
receiveIdType: z.string(),
|
|
70
|
+
messageId: z.string().optional(),
|
|
71
|
+
}).optional(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const notifyLarkStateSchema =
|
|
75
|
+
notifyLarkInputSchema.merge(notifyLarkContextSchema);
|
|
@@ -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
|