@zibby/workflow-templates 0.3.0 → 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/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/code-analysis/nodes/generate-code-node.js +12 -2
- 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,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
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-slack node — deterministic, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Posts a single Block Kit message to Slack via chat.postMessage. We
|
|
5
|
+
* don't use an LLM for this because:
|
|
6
|
+
* - The output is fully specified by the input (no decisions to make).
|
|
7
|
+
* - Notifications need to be fast (<500ms) and predictable.
|
|
8
|
+
* - LLM cost on every alert is silly when the parent has already
|
|
9
|
+
* decided the severity + body.
|
|
10
|
+
*
|
|
11
|
+
* Auth: pulls the Slack OAuth token via @zibby/core's
|
|
12
|
+
* `resolveIntegrationToken('slack')`. Same path slackSkill uses for the
|
|
13
|
+
* LLM-tool variant. Tokens are cached per-process for ~50min so a
|
|
14
|
+
* parent dispatching 20 alerts in a row only pays one round-trip to the
|
|
15
|
+
* backend integrations endpoint.
|
|
16
|
+
*
|
|
17
|
+
* Severity → color + decoration:
|
|
18
|
+
* low → grey (#7f8c8d), no mentions
|
|
19
|
+
* medium → yellow (#f1c40f), no mentions
|
|
20
|
+
* high → orange (#e67e22), mentions appended if caller provided
|
|
21
|
+
* critical → red (#c0392b), mentions appended (caller usually
|
|
22
|
+
* supplies <!subteam^...> or @here)
|
|
23
|
+
*
|
|
24
|
+
* Severity emoji ⏺ in the header keeps the message scannable even when
|
|
25
|
+
* Slack collapses attachment colors on mobile.
|
|
26
|
+
*
|
|
27
|
+
* Failure modes (no retry — sub-graph caller handles retries):
|
|
28
|
+
* - Slack token not connected → throws "slack is not connected" via
|
|
29
|
+
* resolveIntegrationToken. Caller sees SUBGRAPH_NOT_FOUND-shaped err.
|
|
30
|
+
* - Channel not found → Slack API returns ok=false with
|
|
31
|
+
* error="channel_not_found". We surface a clear typed error.
|
|
32
|
+
* - Bot not in channel → error="not_in_channel". Same.
|
|
33
|
+
* - Network blip → fetch throws. Parent's onComplete sees a failed
|
|
34
|
+
* execution row and can re-dispatch.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { SKILLS } from '@zibby/core';
|
|
39
|
+
import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
|
|
40
|
+
|
|
41
|
+
const SEVERITY_COLORS = Object.freeze({
|
|
42
|
+
low: '#7f8c8d',
|
|
43
|
+
medium: '#f1c40f',
|
|
44
|
+
high: '#e67e22',
|
|
45
|
+
critical: '#c0392b',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const SEVERITY_EMOJI = Object.freeze({
|
|
49
|
+
low: ':white_circle:',
|
|
50
|
+
medium: ':large_yellow_circle:',
|
|
51
|
+
high: ':large_orange_circle:',
|
|
52
|
+
critical: ':rotating_light:',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const NotifySlackOutputSchema = z.object({
|
|
56
|
+
delivered: z.boolean().describe('true if chat.postMessage returned ok'),
|
|
57
|
+
channel: z.string().describe('Channel id Slack actually posted to (canonical form).'),
|
|
58
|
+
messageTs: z.string().optional().describe('Slack message timestamp — use as thread_ts for follow-ups.'),
|
|
59
|
+
blocksCount: z.number().int().optional().describe('Block count in the rendered message — diagnostic only.'),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the Slack Block Kit `attachments[0].blocks` payload from the
|
|
64
|
+
* input. Exposed for unit tests so we can pin the rendered shape
|
|
65
|
+
* without going through fetch.
|
|
66
|
+
*/
|
|
67
|
+
export function buildSlackBlocks(input) {
|
|
68
|
+
const {
|
|
69
|
+
severity = 'medium',
|
|
70
|
+
title,
|
|
71
|
+
body,
|
|
72
|
+
sentryLink,
|
|
73
|
+
affectedUsers,
|
|
74
|
+
events,
|
|
75
|
+
release,
|
|
76
|
+
firstSeen,
|
|
77
|
+
codeSnippet,
|
|
78
|
+
actionUrl,
|
|
79
|
+
actionLabel,
|
|
80
|
+
mentions,
|
|
81
|
+
} = input;
|
|
82
|
+
|
|
83
|
+
const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.medium;
|
|
84
|
+
const blocks = [];
|
|
85
|
+
|
|
86
|
+
// Header — severity emoji + uppercased severity + title.
|
|
87
|
+
blocks.push({
|
|
88
|
+
type: 'header',
|
|
89
|
+
text: {
|
|
90
|
+
type: 'plain_text',
|
|
91
|
+
// Slack header text caps at 150 chars; truncate defensively.
|
|
92
|
+
text: `${emoji} ${severity.toUpperCase()} ${title}`.slice(0, 150),
|
|
93
|
+
emoji: true,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Metadata fields (2-column grid). Only rendered when at least one
|
|
98
|
+
// optional field is present — keeps the alert tight for "headline-only" messages.
|
|
99
|
+
const fields = [];
|
|
100
|
+
if (typeof affectedUsers === 'number') {
|
|
101
|
+
fields.push({ type: 'mrkdwn', text: `*Users affected:*\n${affectedUsers}` });
|
|
102
|
+
}
|
|
103
|
+
if (typeof events === 'number') {
|
|
104
|
+
fields.push({ type: 'mrkdwn', text: `*Events:*\n${events}` });
|
|
105
|
+
}
|
|
106
|
+
if (release) {
|
|
107
|
+
fields.push({ type: 'mrkdwn', text: `*Release:*\n\`${release}\`` });
|
|
108
|
+
}
|
|
109
|
+
if (firstSeen) {
|
|
110
|
+
fields.push({ type: 'mrkdwn', text: `*First seen:*\n${firstSeen}` });
|
|
111
|
+
}
|
|
112
|
+
if (fields.length > 0) {
|
|
113
|
+
blocks.push({ type: 'section', fields });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Body — mrkdwn-rendered. Empty body still posts (header alone is OK).
|
|
117
|
+
if (body && body.trim().length > 0) {
|
|
118
|
+
blocks.push({
|
|
119
|
+
type: 'section',
|
|
120
|
+
text: { type: 'mrkdwn', text: body },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Code snippet — fenced triple-backtick block. Slack treats these as
|
|
125
|
+
// monospaced when posted inside a mrkdwn section.
|
|
126
|
+
if (codeSnippet) {
|
|
127
|
+
blocks.push({
|
|
128
|
+
type: 'section',
|
|
129
|
+
text: { type: 'mrkdwn', text: '```' + codeSnippet + '```' },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Action buttons.
|
|
134
|
+
const actions = [];
|
|
135
|
+
if (sentryLink) {
|
|
136
|
+
actions.push({
|
|
137
|
+
type: 'button',
|
|
138
|
+
text: { type: 'plain_text', text: 'Open in Sentry' },
|
|
139
|
+
url: sentryLink,
|
|
140
|
+
style: 'primary',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (actionUrl && actionLabel) {
|
|
144
|
+
actions.push({
|
|
145
|
+
type: 'button',
|
|
146
|
+
text: { type: 'plain_text', text: actionLabel.slice(0, 40) },
|
|
147
|
+
url: actionUrl,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (actions.length > 0) {
|
|
151
|
+
blocks.push({ type: 'actions', elements: actions });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Mentions go into a small-text context block at the bottom so they
|
|
155
|
+
// don't dominate the visual. Caller-supplied strings are joined with
|
|
156
|
+
// spaces. We don't validate the mention syntax — Slack will silently
|
|
157
|
+
// ignore malformed ones.
|
|
158
|
+
if (Array.isArray(mentions) && mentions.length > 0) {
|
|
159
|
+
blocks.push({
|
|
160
|
+
type: 'context',
|
|
161
|
+
elements: [{ type: 'mrkdwn', text: mentions.join(' ') }],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return blocks;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Post the message via Slack's chat.postMessage. Exposed for tests so
|
|
170
|
+
* we can stub fetch + assert payload shape without invoking
|
|
171
|
+
* resolveIntegrationToken.
|
|
172
|
+
*/
|
|
173
|
+
export async function postToSlack({ token, channel, blocks, text }) {
|
|
174
|
+
// `text` is the plain-text fallback shown in notifications / when
|
|
175
|
+
// blocks can't render. Slack recommends ALWAYS setting it.
|
|
176
|
+
const payload = { channel, blocks, text };
|
|
177
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${token}`,
|
|
181
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify(payload),
|
|
184
|
+
});
|
|
185
|
+
const data = await res.json().catch(() => ({}));
|
|
186
|
+
if (!data.ok) {
|
|
187
|
+
const err = new Error(`Slack chat.postMessage failed: ${data.error || `http ${res.status}`}`);
|
|
188
|
+
err.code = data.error || 'unknown';
|
|
189
|
+
err.status = res.status;
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
return { channel: data.channel, ts: data.ts };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const notifySlackNode = {
|
|
196
|
+
name: 'notify_slack',
|
|
197
|
+
// Declares the Slack integration as a hard requirement so the
|
|
198
|
+
// marketplace card surfaces "Slack required" via the backend's
|
|
199
|
+
// automatic deriveRequiredIntegrations(). The MCP slack server tools
|
|
200
|
+
// aren't actually invoked here (custom-execute path), but the
|
|
201
|
+
// declaration is the canonical way to wire the marketplace gate.
|
|
202
|
+
skills: [SKILLS.SLACK],
|
|
203
|
+
outputSchema: NotifySlackOutputSchema,
|
|
204
|
+
// 15s — chat.postMessage is fast, and we want to fail-out quick if
|
|
205
|
+
// Slack is degraded so the parent's sync-dispatch doesn't hang.
|
|
206
|
+
timeout: 15 * 1000,
|
|
207
|
+
execute: async (context) => {
|
|
208
|
+
// Custom-execute gets either the merged state object or the new
|
|
209
|
+
// {state, agent, ...} context wrapper. Normalize to a plain state.
|
|
210
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
211
|
+
? context.state.getAll()
|
|
212
|
+
: context;
|
|
213
|
+
|
|
214
|
+
const channel = state.channel;
|
|
215
|
+
if (!channel) {
|
|
216
|
+
throw new Error('notify-slack: input.channel is required');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Resolve the bot token. The cache inside resolveIntegrationToken
|
|
220
|
+
// means this is a single HTTP RTT the first time per process; later
|
|
221
|
+
// dispatches in the same Fargate task are zero-cost.
|
|
222
|
+
const { token } = await resolveIntegrationToken('slack');
|
|
223
|
+
|
|
224
|
+
const blocks = buildSlackBlocks(state);
|
|
225
|
+
// Plain-text fallback — Slack renders this in mobile push
|
|
226
|
+
// notifications when blocks are absent. Severity + title is the
|
|
227
|
+
// most useful summary in <100 chars.
|
|
228
|
+
const text = `${(state.severity || 'medium').toUpperCase()}: ${state.title}`.slice(0, 200);
|
|
229
|
+
|
|
230
|
+
const result = await postToSlack({ token, channel, blocks, text });
|
|
231
|
+
return {
|
|
232
|
+
delivered: true,
|
|
233
|
+
channel: result.channel,
|
|
234
|
+
messageTs: result.ts,
|
|
235
|
+
blocksCount: blocks.length,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "notify-slack",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Post a structured alert to a Slack channel — child workflow, dispatched by parent workflows (Sentry triage / autofix / incident / etc.) 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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-slack — three-schema state model.
|
|
3
|
+
*
|
|
4
|
+
* Designed to be invoked as a sub-graph child from any parent workflow
|
|
5
|
+
* that wants to fan-out alerts to Slack. The shape is intentionally
|
|
6
|
+
* provider-neutral on the wire (severity / title / body / link / users)
|
|
7
|
+
* so that notify-lark can accept the SAME inputs without the parent
|
|
8
|
+
* having to branch on which notifier it's dispatching to.
|
|
9
|
+
*
|
|
10
|
+
* Three schemas:
|
|
11
|
+
* - notifySlackInputSchema — payload from the parent's
|
|
12
|
+
* `dispatchSubgraph('notify-slack', { input: … })`
|
|
13
|
+
* - notifySlackContextSchema — runner-injected fields + node outputs
|
|
14
|
+
* - notifySlackStateSchema — merge of the two (tests + tooling)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export const SEVERITIES = /** @type {const} */ (['low', 'medium', 'high', 'critical']);
|
|
20
|
+
|
|
21
|
+
// ── Parent-supplied payload ────────────────────────────────────────────
|
|
22
|
+
// Keep this minimal + provider-neutral. Anything Slack-specific (channel
|
|
23
|
+
// id format, Block Kit, mention strings) is RESOLVED inside the node,
|
|
24
|
+
// not declared on the inputSchema.
|
|
25
|
+
export const notifySlackInputSchema = z.object({
|
|
26
|
+
severity: z.enum(SEVERITIES)
|
|
27
|
+
.default('medium')
|
|
28
|
+
.describe('Alert severity. Drives color + mention strategy.'),
|
|
29
|
+
|
|
30
|
+
title: z.string().min(1).max(300)
|
|
31
|
+
.describe('Short one-line headline (renders in the Slack header).'),
|
|
32
|
+
|
|
33
|
+
body: z.string().max(3000).optional()
|
|
34
|
+
.describe('Body text. Supports Slack mrkdwn (e.g. *bold*, `code`, <url|text>).'),
|
|
35
|
+
|
|
36
|
+
channel: z.string().min(1).max(100)
|
|
37
|
+
.describe('Target channel ID (C012345) or #channel-name. Provided by the parent at dispatch.'),
|
|
38
|
+
|
|
39
|
+
// ── Sentry-flavored optional context — used to render rich blocks
|
|
40
|
+
// when the alert originates from a Sentry workflow. Other workflows
|
|
41
|
+
// can ignore these.
|
|
42
|
+
sentryLink: z.string().url().optional()
|
|
43
|
+
.describe('Sentry issue/event URL. Adds an "Open in Sentry" button.'),
|
|
44
|
+
|
|
45
|
+
affectedUsers: z.number().int().min(0).optional()
|
|
46
|
+
.describe('Count of users hit by this issue (rendered in the fields block).'),
|
|
47
|
+
|
|
48
|
+
events: z.number().int().min(0).optional()
|
|
49
|
+
.describe('Total event count for this issue.'),
|
|
50
|
+
|
|
51
|
+
release: z.string().max(120).optional()
|
|
52
|
+
.describe('Release/version tag this error appeared in.'),
|
|
53
|
+
|
|
54
|
+
firstSeen: z.string().optional()
|
|
55
|
+
.describe('ISO timestamp or human-friendly "N min ago".'),
|
|
56
|
+
|
|
57
|
+
codeSnippet: z.string().max(2000).optional()
|
|
58
|
+
.describe('Optional code block to render verbatim (e.g. the throwing line + context).'),
|
|
59
|
+
|
|
60
|
+
// Action button — optional, opens a URL when clicked.
|
|
61
|
+
actionUrl: z.string().url().optional()
|
|
62
|
+
.describe('Secondary URL (e.g. PR link). Renders as a non-primary button next to "Open in Sentry".'),
|
|
63
|
+
actionLabel: z.string().max(40).optional()
|
|
64
|
+
.describe('Label for the secondary action button (e.g. "View PR").'),
|
|
65
|
+
|
|
66
|
+
// ── Mentions — Slack-specific syntax provided by the caller. Parent
|
|
67
|
+
// decides who to mention based on severity (e.g. <!subteam^S0...>
|
|
68
|
+
// for critical, <@U_USERID> for the deploy author).
|
|
69
|
+
mentions: z.array(z.string().max(60))
|
|
70
|
+
.max(20)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('Slack mention strings appended to the context block, e.g. ["<!here>", "<@U123>"].'),
|
|
73
|
+
|
|
74
|
+
// Idempotency: if the parent retries dispatch (or two parents dispatch
|
|
75
|
+
// the same alert), we want at most ONE Slack post. ts is returned in
|
|
76
|
+
// the output for thread replies later.
|
|
77
|
+
idempotencyKey: z.string().max(128).optional()
|
|
78
|
+
.describe('Caller-supplied dedup key. The node still posts; idempotency is the parent\'s job today.'),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const notifySlackContextSchema = z.object({
|
|
82
|
+
// Node outputs land here keyed by node name.
|
|
83
|
+
notify_slack: z.object({
|
|
84
|
+
delivered: z.boolean(),
|
|
85
|
+
channel: z.string(),
|
|
86
|
+
messageTs: z.string().optional(),
|
|
87
|
+
blocksCount: z.number().int().optional(),
|
|
88
|
+
}).optional()
|
|
89
|
+
.describe('Output of the notify_slack node — set after dispatch completes.'),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const notifySlackStateSchema =
|
|
93
|
+
notifySlackInputSchema.merge(notifySlackContextSchema);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zibby/workflow-templates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, sentry-triage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"./code-analysis/*": "./code-analysis/*",
|
|
16
16
|
"./generate-test-cases": "./generate-test-cases/graph.mjs",
|
|
17
17
|
"./generate-test-cases/*": "./generate-test-cases/*",
|
|
18
|
+
"./notify-slack": "./notify-slack/graph.mjs",
|
|
19
|
+
"./notify-slack/*": "./notify-slack/*",
|
|
20
|
+
"./notify-lark": "./notify-lark/graph.mjs",
|
|
21
|
+
"./notify-lark/*": "./notify-lark/*",
|
|
22
|
+
"./sentry-triage": "./sentry-triage/graph.mjs",
|
|
23
|
+
"./sentry-triage/*": "./sentry-triage/*",
|
|
18
24
|
"./package.json": "./package.json"
|
|
19
25
|
},
|
|
20
26
|
"scripts": {
|
|
@@ -42,6 +48,9 @@
|
|
|
42
48
|
"browser-test-automation/",
|
|
43
49
|
"code-analysis/",
|
|
44
50
|
"generate-test-cases/",
|
|
51
|
+
"notify-slack/",
|
|
52
|
+
"notify-lark/",
|
|
53
|
+
"sentry-triage/",
|
|
45
54
|
"index.js",
|
|
46
55
|
"register-nodes.js",
|
|
47
56
|
"global-setup.js",
|
|
@@ -57,7 +66,7 @@
|
|
|
57
66
|
},
|
|
58
67
|
"dependencies": {
|
|
59
68
|
"@anthropic-ai/sdk": "^0.88.0",
|
|
60
|
-
"@zibby/agent-workflow": "^0.4.
|
|
69
|
+
"@zibby/agent-workflow": "^0.4.2",
|
|
61
70
|
"axios": "^1.15.0",
|
|
62
71
|
"handlebars": "^4.7.9",
|
|
63
72
|
"zod": "^3.23.0 || ^4.0.0"
|