@zibby/workflow-templates 0.4.2 → 0.7.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/index.js +36 -49
- package/notify-notion/brand/notion-logo.svg +4 -0
- package/notify-notion/icon.png +0 -0
- package/package.json +2 -1
- package/sentry-triage/graph.mjs +36 -37
- package/sentry-triage/icon.png +0 -0
- package/sentry-triage/nodes/classify-node.js +97 -12
- package/sentry-triage/nodes/dispatch-node.js +262 -0
- package/sentry-triage/nodes/fetch-issues-node.js +130 -17
- package/sentry-triage/package.json +2 -1
- package/sentry-triage/state.js +26 -69
- package/sentry-triage/nodes/dispatch-alerts-node.js +0 -191
- package/sentry-triage/nodes/filter-noise-node.js +0 -112
- package/sentry-triage/prompts/classify.md +0 -76
- package/sentry-triage/prompts/fetch-issues.md +0 -66
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch_alerts node — LLM-driven dispatcher with author routing.
|
|
3
|
+
*
|
|
4
|
+
* The agent reads each classified issue (+ its Sentry suspectCommits
|
|
5
|
+
* author, when available) and decides per-issue where to send the
|
|
6
|
+
* alert. Three layers of decisioning, top-down:
|
|
7
|
+
*
|
|
8
|
+
* 1. `DISPATCH_RULES` (free-form natural-language override)
|
|
9
|
+
* — when set, the agent applies these rules verbatim and treats
|
|
10
|
+
* the structured env vars below as defaults. For advanced
|
|
11
|
+
* routing ("security tag always pages @security regardless of
|
|
12
|
+
* severity", "weekend hours quiet unless CRITICAL", etc.).
|
|
13
|
+
*
|
|
14
|
+
* 2. Structured env vars (the 90% path):
|
|
15
|
+
* SLACK_CHANNEL / LARK_RECEIVE_ID — pick provider, channel fallback
|
|
16
|
+
* SEVERITY_THRESHOLD — skip anything below
|
|
17
|
+
* ROUTING_PREFER_AUTHOR — if suspectCommit.author email
|
|
18
|
+
* resolves to a user, DM them
|
|
19
|
+
* ROUTING_HIGH_SEVERITY_GROUP — handle/id of a Slack
|
|
20
|
+
* usergroup (@oncall) that gets
|
|
21
|
+
* mentioned on CRITICAL/HIGH
|
|
22
|
+
* SLACK_MENTIONS / LARK_MENTIONS — flat mention list, CRITICAL only
|
|
23
|
+
*
|
|
24
|
+
* 3. Built-in defaults (when neither rules nor env set anything):
|
|
25
|
+
* - threshold MEDIUM, no author DM, channel-only post.
|
|
26
|
+
*
|
|
27
|
+
* Author-routing tools the agent has access to (from the slack/lark
|
|
28
|
+
* skills via SKILLS.CHAT_NOTIFY):
|
|
29
|
+
* slack:
|
|
30
|
+
* - slack_lookup_user_by_email email → user id (DM target)
|
|
31
|
+
* - slack_list_usergroups list @oncall etc., with handle + count
|
|
32
|
+
* - slack_get_usergroup_members expand a group → user ids
|
|
33
|
+
* - slack_post_message DM user id OR channel
|
|
34
|
+
* lark:
|
|
35
|
+
* - lark_lookup_user_by_email email → open_id (DM target)
|
|
36
|
+
* - lark_send_message DM open_id OR oc_* group chat
|
|
37
|
+
*
|
|
38
|
+
* Provider selection: chatNotifySkill.resolve() picks slack or lark
|
|
39
|
+
* based on which env var (`SLACK_CHANNEL` or `LARK_RECEIVE_ID`) the
|
|
40
|
+
* project sets. The LLM only sees one provider's tools, so it can't
|
|
41
|
+
* accidentally fan out to the wrong workspace.
|
|
42
|
+
*
|
|
43
|
+
* Reliability: outputSchema enforces a per-dispatch record + summary.
|
|
44
|
+
* A malformed LLM response triggers a retry with the schema embedded.
|
|
45
|
+
*
|
|
46
|
+
* ENV tab config — required:
|
|
47
|
+
* SLACK_CHANNEL OR LARK_RECEIVE_ID — provider selector + channel fallback
|
|
48
|
+
*
|
|
49
|
+
* ENV tab config — optional:
|
|
50
|
+
* SEVERITY_THRESHOLD — NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
|
|
51
|
+
* ROUTING_PREFER_AUTHOR — "true" enables suspectCommit author DM
|
|
52
|
+
* ROUTING_HIGH_SEVERITY_GROUP — e.g. "@oncall" or "S012ABC" (Slack usergroup id)
|
|
53
|
+
* SLACK_MENTIONS / LARK_MENTIONS — JSON array of mentions on CRITICAL
|
|
54
|
+
* DISPATCH_RULES — free-form natural-language overrides
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { z, SKILLS } from '@zibby/core';
|
|
58
|
+
import { SEVERITY_LEVELS } from '../state.js';
|
|
59
|
+
|
|
60
|
+
const DispatchedRecordSchema = z.object({
|
|
61
|
+
issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
|
|
62
|
+
severity: z.enum(SEVERITY_LEVELS),
|
|
63
|
+
status: z.enum(['sent', 'skipped', 'failed']),
|
|
64
|
+
// Who actually received this message. Helps post-hoc auditing of
|
|
65
|
+
// routing decisions ("why did the agent send this to @sarah?").
|
|
66
|
+
recipient: z.object({
|
|
67
|
+
kind: z.enum(['channel', 'user_dm', 'usergroup']).optional(),
|
|
68
|
+
id: z.string().optional(),
|
|
69
|
+
label: z.string().optional(),
|
|
70
|
+
}).optional(),
|
|
71
|
+
messageTs: z.string().optional(), // Slack
|
|
72
|
+
messageId: z.string().optional(), // Lark
|
|
73
|
+
detail: z.string().optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const DispatchAlertsOutputSchema = z.object({
|
|
77
|
+
dispatched: z.array(DispatchedRecordSchema),
|
|
78
|
+
summary: z.object({
|
|
79
|
+
total: z.number().describe('Number of messages POSTED (not issues — batched groups count as 1).'),
|
|
80
|
+
sent: z.number(),
|
|
81
|
+
skipped: z.number(),
|
|
82
|
+
failed: z.number(),
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const SEVERITY_ORDER = ['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
|
87
|
+
|
|
88
|
+
const DISPATCH_PROMPT = (state = {}) => {
|
|
89
|
+
const issues = state?.fetch_issues?.issues || [];
|
|
90
|
+
const classifications = state?.classify?.classifications || [];
|
|
91
|
+
|
|
92
|
+
const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
|
|
93
|
+
const slackChannel = process.env.SLACK_CHANNEL || '';
|
|
94
|
+
const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
|
|
95
|
+
const preferAuthor = /^(1|true|yes|on)$/i.test(process.env.ROUTING_PREFER_AUTHOR || '');
|
|
96
|
+
const highSevGroup = process.env.ROUTING_HIGH_SEVERITY_GROUP || '';
|
|
97
|
+
const dispatchRules = process.env.DISPATCH_RULES || '';
|
|
98
|
+
|
|
99
|
+
// ── No-op short-circuit ─────────────────────────────────────────
|
|
100
|
+
// Same three "nothing to do this run" cases as before — keep the
|
|
101
|
+
// run green without forcing channel setup.
|
|
102
|
+
const minSeverityRank = SEVERITY_ORDER.indexOf(threshold);
|
|
103
|
+
const aboveThreshold = minSeverityRank < 0
|
|
104
|
+
? classifications
|
|
105
|
+
: classifications.filter((c) => SEVERITY_ORDER.indexOf(c.severity) >= minSeverityRank);
|
|
106
|
+
|
|
107
|
+
if (issues.length === 0 || classifications.length === 0 || aboveThreshold.length === 0) {
|
|
108
|
+
const reason =
|
|
109
|
+
issues.length === 0 ? 'fetch_issues returned no issues' :
|
|
110
|
+
classifications.length === 0 ? 'classifier emitted no records' :
|
|
111
|
+
`all ${classifications.length} issue(s) below SEVERITY_THRESHOLD=${threshold}`;
|
|
112
|
+
return `No Sentry issues to dispatch this run (${reason}).
|
|
113
|
+
|
|
114
|
+
Return this exact JSON envelope and call no tools:
|
|
115
|
+
|
|
116
|
+
\`\`\`json
|
|
117
|
+
{ "dispatched": [], "summary": { "total": 0, "sent": 0, "skipped": 0, "failed": 0 } }
|
|
118
|
+
\`\`\`
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Provider selection ──────────────────────────────────────────
|
|
123
|
+
let provider, postTool, lookupTool, channelId, mentionsRaw;
|
|
124
|
+
if (slackChannel) {
|
|
125
|
+
provider = 'slack';
|
|
126
|
+
postTool = 'slack_post_message';
|
|
127
|
+
lookupTool = 'slack_lookup_user_by_email';
|
|
128
|
+
channelId = slackChannel;
|
|
129
|
+
mentionsRaw = process.env.SLACK_MENTIONS || '[]';
|
|
130
|
+
} else if (larkReceiveId) {
|
|
131
|
+
provider = 'lark';
|
|
132
|
+
postTool = 'lark_send_message';
|
|
133
|
+
lookupTool = 'lark_lookup_user_by_email';
|
|
134
|
+
channelId = larkReceiveId;
|
|
135
|
+
mentionsRaw = process.env.LARK_MENTIONS || '[]';
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error(
|
|
138
|
+
'sentry-triage has issues to dispatch but no destination configured. ' +
|
|
139
|
+
'Go to Project Settings -> ENV and set ONE of:\n' +
|
|
140
|
+
' - SLACK_CHANNEL=#your-alerts-channel (uses connected Slack integration)\n' +
|
|
141
|
+
' - LARK_RECEIVE_ID=oc_xxxxxxxx (uses connected Lark integration)\n' +
|
|
142
|
+
'The integration OAuth token gives the workflow auth, but you still need to tell it WHERE to post.'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let mentions;
|
|
147
|
+
try { mentions = JSON.parse(mentionsRaw); } catch { mentions = []; }
|
|
148
|
+
if (!Array.isArray(mentions)) mentions = [];
|
|
149
|
+
|
|
150
|
+
// ── Routing policy block ────────────────────────────────────────
|
|
151
|
+
// Two voices: a default policy (always rendered, derived from env
|
|
152
|
+
// vars) and the optional natural-language override (rendered only
|
|
153
|
+
// when DISPATCH_RULES is set). When the override exists, the
|
|
154
|
+
// agent is told to treat it as authoritative.
|
|
155
|
+
const defaultPolicyLines = [
|
|
156
|
+
`Skip any classification below severity ${threshold}.`,
|
|
157
|
+
`Channel fallback (every alert at minimum goes here): ${JSON.stringify(channelId)}`,
|
|
158
|
+
];
|
|
159
|
+
if (preferAuthor) {
|
|
160
|
+
defaultPolicyLines.push(
|
|
161
|
+
`Author-DM enabled: when an issue has \`suspectCommits[0].authorEmail\`, FIRST call \`${lookupTool}({ email })\`. ` +
|
|
162
|
+
`If \`ok:true\`, send the alert as a DM to that user (use their id as the recipient — Slack ${'`channel`'}, Lark ${'`receive_id`'}). ` +
|
|
163
|
+
`Also still post to the channel fallback above so the team has visibility. ` +
|
|
164
|
+
`If \`ok:false\` or there's no email, channel-only.`
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
defaultPolicyLines.push(
|
|
168
|
+
`Author-DM disabled (ROUTING_PREFER_AUTHOR=false). Post all alerts to the channel fallback.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (highSevGroup) {
|
|
172
|
+
defaultPolicyLines.push(
|
|
173
|
+
provider === 'slack'
|
|
174
|
+
? `High-severity escalation: on CRITICAL/HIGH, mention the Slack usergroup ${JSON.stringify(highSevGroup)} in the channel message. ` +
|
|
175
|
+
`If it's a handle (starts with @), call \`slack_list_usergroups\` once to resolve handle → id, then mention as \`<!subteam^ID>\`. ` +
|
|
176
|
+
`If it already looks like an id (starts with "S"), mention directly as \`<!subteam^${highSevGroup}>\`.`
|
|
177
|
+
: `High-severity escalation: on CRITICAL/HIGH, also send the alert to the chat/user ${JSON.stringify(highSevGroup)} (Lark receive_id).`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (mentions.length > 0) {
|
|
181
|
+
defaultPolicyLines.push(`CRITICAL messages prepend: ${JSON.stringify(mentions.join(' '))}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const policyBlock = defaultPolicyLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
185
|
+
|
|
186
|
+
const overrideBlock = dispatchRules
|
|
187
|
+
? `\n\n# DISPATCH_RULES (override — authoritative)\nThe project has set custom routing rules. Apply these verbatim; the defaults above are only fallbacks for behavior the rules don't cover.\n\n${dispatchRules.trim()}\n`
|
|
188
|
+
: '';
|
|
189
|
+
|
|
190
|
+
// ── Prompt body ─────────────────────────────────────────────────
|
|
191
|
+
return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${postTool}** tool (and the lookup helpers below for author routing).
|
|
192
|
+
|
|
193
|
+
# Default routing policy
|
|
194
|
+
${policyBlock}${overrideBlock}
|
|
195
|
+
|
|
196
|
+
# Severity scale
|
|
197
|
+
${SEVERITY_LEVELS.join(' < ')}
|
|
198
|
+
|
|
199
|
+
# Tools you should use
|
|
200
|
+
- \`${postTool}\` — post the message (channel id OR user/DM id for the recipient field).
|
|
201
|
+
- \`${lookupTool}\` — resolve an email to a user id. **Important**: this can return \`{ ok: false }\` — handle that by falling back to channel-only, don't retry with variations of the email.
|
|
202
|
+
${provider === 'slack' ? '- `slack_list_usergroups` / `slack_get_usergroup_members` — expand @group → user ids.' : ''}
|
|
203
|
+
|
|
204
|
+
# Your judgment (unchanged from before)
|
|
205
|
+
- Batch issues with the same culprit / metadata.filename into ONE message.
|
|
206
|
+
- De-dupe near-duplicates ("seen N times"). Keep messages short. Lead with severity in *[BRACKETS]*. Include Sentry permalinks.
|
|
207
|
+
|
|
208
|
+
# Message format
|
|
209
|
+
\`\`\`
|
|
210
|
+
*[CRITICAL]* TypeError: Cannot read 'id' of undefined
|
|
211
|
+
12 users hit /checkout — likely regression on r1234.
|
|
212
|
+
📍 handleCheckout(checkout.ts) · 47 events
|
|
213
|
+
Suspect commit: a1b2c3d4 by sarah@acme.com — "refactor checkout state"
|
|
214
|
+
https://sentry.io/.../1234/
|
|
215
|
+
\`\`\`
|
|
216
|
+
|
|
217
|
+
# Output (outputSchema-enforced)
|
|
218
|
+
Return ONE record per dispatch call you actually made (or skipped/failed). \`issueIds\` is an array — batched messages carry every issue in the group. \`recipient\` records who got the message (channel id, user id, or usergroup id) so the audit trail shows the routing decision.
|
|
219
|
+
|
|
220
|
+
\`\`\`json
|
|
221
|
+
{
|
|
222
|
+
"dispatched": [
|
|
223
|
+
{
|
|
224
|
+
"issueIds": ["1", "5"],
|
|
225
|
+
"severity": "CRITICAL",
|
|
226
|
+
"status": "sent",
|
|
227
|
+
"recipient": { "kind": "user_dm", "id": "U012ABC", "label": "sarah@acme.com" }${provider === 'slack' ? ',\n "messageTs": "1716109330.555"' : ',\n "messageId": "om_xxxxx"'}
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
"summary": { "total": 1, "sent": 1, "skipped": 0, "failed": 0 }
|
|
231
|
+
}
|
|
232
|
+
\`\`\`
|
|
233
|
+
|
|
234
|
+
# Issues + classifications + suspect commits
|
|
235
|
+
|
|
236
|
+
Each entry has the Sentry issue, the classifier's verdict, and any suspect commits Sentry's GitHub integration could blame. **An empty \`suspectCommits\` array means the team hasn't set up Sentry's GitHub integration OR the file wasn't touched in the last 14 days** — fall back to channel-only routing in that case.
|
|
237
|
+
|
|
238
|
+
\`\`\`json
|
|
239
|
+
${JSON.stringify(
|
|
240
|
+
issues.map((issue) => {
|
|
241
|
+
const c = classifications.find((x) => String(x.issueId) === String(issue.id));
|
|
242
|
+
return { ...issue, classification: c || { severity: 'LOW' } };
|
|
243
|
+
}),
|
|
244
|
+
null,
|
|
245
|
+
2,
|
|
246
|
+
)}
|
|
247
|
+
\`\`\`
|
|
248
|
+
|
|
249
|
+
# Rules
|
|
250
|
+
- Skip below-threshold issues silently (status="skipped"; no chat call). Include them in \`dispatched\` so the run record is complete.
|
|
251
|
+
- DON'T invent severities, issue IDs, or email addresses. Only use what's in the data block above.
|
|
252
|
+
- DON'T post more messages than necessary. If 5 issues are clearly one bug, post 1 message.
|
|
253
|
+
- DO post if in doubt — under-paging is worse than over-paging.
|
|
254
|
+
`;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const dispatchNode = {
|
|
258
|
+
name: 'dispatch_alerts',
|
|
259
|
+
skills: [SKILLS.CHAT_NOTIFY],
|
|
260
|
+
outputSchema: DispatchAlertsOutputSchema,
|
|
261
|
+
prompt: DISPATCH_PROMPT,
|
|
262
|
+
};
|
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fetch_issues
|
|
2
|
+
* fetch_issues — DETERMINISTIC. Calls Sentry's REST API directly via
|
|
3
|
+
* the @zibby/skills client. No LLM, no MCP tool round-trip.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Why deterministic: the previous LLM-driven version hard-coded the
|
|
6
|
+
* query string and explicitly forbade filtering or follow-up calls.
|
|
7
|
+
* The LLM added zero judgment — just one round-trip of latency and
|
|
8
|
+
* ~$0.01-0.05 of token cost per run. At hourly cadence across many
|
|
9
|
+
* customers, that compounds; deterministic also removes the "LLM
|
|
10
|
+
* hallucinated query string" failure mode.
|
|
7
11
|
*
|
|
8
|
-
* Why
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Why still declare `skills: [SKILLS.SENTRY]`: the backend bundler
|
|
13
|
+
* reads this to build `workflow.requiredIntegrations`, which the
|
|
14
|
+
* marketplace deploy modal uses to gate install until Sentry is
|
|
15
|
+
* connected. Without it, users could install with no Sentry token
|
|
16
|
+
* wired up and the first run would 401. The skill's runtime tool
|
|
17
|
+
* injection is a no-op here (no prompt for an LLM to call them), but
|
|
18
|
+
* the integration-requirement signal still matters — same pattern as
|
|
19
|
+
* ai-spend-weekly-digest's fetch-spending-node.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Auth: sentryListIssues uses resolveIntegrationToken('sentry') which
|
|
22
|
+
* hits the backend's project-scoped resolver via PROJECT_API_TOKEN +
|
|
23
|
+
* PROGRESS_API_URL env vars (set on every Fargate task by
|
|
24
|
+
* workflow-executor.js).
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
|
-
import { z
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { SKILLS } from '@zibby/core';
|
|
29
|
+
import { sentryListIssues, sentryGetIssue } from '@zibby/skills/sentry';
|
|
30
|
+
|
|
31
|
+
// Per-commit shape Sentry returns under `suspectCommits[]`. We only
|
|
32
|
+
// surface what the dispatch agent actually uses for routing — author
|
|
33
|
+
// email (→ slack_lookup_user_by_email) + a short SHA + the commit
|
|
34
|
+
// message so the agent can sanity-check "is this commit plausibly the
|
|
35
|
+
// cause?". Everything else (date, repository url, etc.) is dropped to
|
|
36
|
+
// keep the per-issue payload small.
|
|
37
|
+
const SuspectCommitShape = z.object({
|
|
38
|
+
id: z.string().optional(),
|
|
39
|
+
shortId: z.string().optional(),
|
|
40
|
+
message: z.string().optional(),
|
|
41
|
+
authorEmail: z.string().optional(),
|
|
42
|
+
authorName: z.string().optional(),
|
|
43
|
+
});
|
|
19
44
|
|
|
20
45
|
const IssueShape = z.object({
|
|
21
46
|
id: z.string(),
|
|
@@ -34,19 +59,107 @@ const IssueShape = z.object({
|
|
|
34
59
|
value: z.string().optional(),
|
|
35
60
|
filename: z.string().optional(),
|
|
36
61
|
}).optional(),
|
|
62
|
+
// Populated by the per-issue sentryGetIssue() fetch below. Empty
|
|
63
|
+
// array when Sentry's GitHub integration can't blame the issue's
|
|
64
|
+
// stack frames to any recent commits (no integration / no code
|
|
65
|
+
// mapping / file untouched in 14 days).
|
|
66
|
+
suspectCommits: z.array(SuspectCommitShape).optional(),
|
|
37
67
|
});
|
|
38
68
|
|
|
39
69
|
const FetchIssuesOutputSchema = z.object({
|
|
40
70
|
issues: z.array(IssueShape),
|
|
41
|
-
fetchedAt: z.string()
|
|
71
|
+
fetchedAt: z.string(),
|
|
42
72
|
});
|
|
43
73
|
|
|
44
74
|
export const fetchIssuesNode = {
|
|
45
75
|
name: 'fetch_issues',
|
|
46
76
|
skills: [SKILLS.SENTRY],
|
|
47
77
|
outputSchema: FetchIssuesOutputSchema,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
execute: async (context) => {
|
|
79
|
+
// State access pattern mirrors fetch-spending-node — the framework
|
|
80
|
+
// passes a context whose `.state.getAll()` returns the flat state,
|
|
81
|
+
// but tests sometimes pass the state object directly as context.
|
|
82
|
+
const state = (context?.state && typeof context.state.getAll === 'function')
|
|
83
|
+
? context.state.getAll()
|
|
84
|
+
: context;
|
|
85
|
+
|
|
86
|
+
const sinceMinutes = Number(state?.sinceMinutes) || 60;
|
|
87
|
+
const query = `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`;
|
|
88
|
+
|
|
89
|
+
// Surface intent BEFORE the call. Deterministic nodes don't go
|
|
90
|
+
// through an LLM that would print its reasoning, so the activity
|
|
91
|
+
// panel would otherwise just show "started" then a result count —
|
|
92
|
+
// operators have no way to tell what query ran or whether the empty
|
|
93
|
+
// result was "Sentry returned nothing" vs "we asked the wrong
|
|
94
|
+
// question". Two-line preamble fixes that without log-spam.
|
|
95
|
+
console.log(`Sentry query: ${query}`);
|
|
96
|
+
console.log(`Sort: new (firstSeen desc) · Limit: 100`);
|
|
97
|
+
|
|
98
|
+
const issues = await sentryListIssues({
|
|
99
|
+
query,
|
|
100
|
+
// Sentry's /projects/{org}/{project}/issues/ endpoint only accepts
|
|
101
|
+
// these sort keys: date | new | priority | freq | user. `new` sorts
|
|
102
|
+
// by firstSeen desc which matches our triage intent (newest issues
|
|
103
|
+
// surface first). `created` was wrong and made Sentry return 400.
|
|
104
|
+
sort: 'new',
|
|
105
|
+
// 100 issues is the practical ceiling for a triage notification.
|
|
106
|
+
// Beyond that, classify+dispatch lose signal — a "deluge" digest
|
|
107
|
+
// tells the user nothing actionable. If a customer regularly
|
|
108
|
+
// exceeds 100/hour they need to tighten the Sentry filters
|
|
109
|
+
// upstream, not raise this cap.
|
|
110
|
+
limit: 100,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Always log the count + a head sample. On 0 issues this prints
|
|
114
|
+
// "Fetched 0 issues" which is the actionable signal — operator
|
|
115
|
+
// knows the query worked but there's nothing new to triage.
|
|
116
|
+
console.log(`Fetched ${issues.length} issue${issues.length === 1 ? '' : 's'} from Sentry`);
|
|
117
|
+
if (issues.length > 0) {
|
|
118
|
+
const preview = issues.slice(0, 5).map((i) => ` - ${i.shortId || i.id} [${i.level || '?'}] ${i.title || '(no title)'}`);
|
|
119
|
+
console.log(preview.join('\n'));
|
|
120
|
+
if (issues.length > 5) console.log(` ... (${issues.length - 5} more)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Enrich each issue with `suspectCommits` (author email + SHA +
|
|
124
|
+
// commit message). The /issues/ list endpoint does NOT include
|
|
125
|
+
// suspectCommits — only /issues/<id>/ does. So we do N parallel
|
|
126
|
+
// GETs to hydrate. Practical concerns:
|
|
127
|
+
//
|
|
128
|
+
// - N is capped at 100 (the list limit above). One parallel
|
|
129
|
+
// batch finishes in ~1-2s against sentry.io.
|
|
130
|
+
// - Per-issue failures are swallowed (logged + empty array)
|
|
131
|
+
// so one 404 doesn't break the entire run. The classifier
|
|
132
|
+
// downstream doesn't NEED suspectCommits to function — it's
|
|
133
|
+
// a routing hint, not a classification input.
|
|
134
|
+
// - When the customer hasn't installed Sentry's GitHub
|
|
135
|
+
// integration OR hasn't configured Code Mappings, every
|
|
136
|
+
// issue will come back with suspectCommits=[]. That's the
|
|
137
|
+
// designed-for steady state; dispatch agent falls back to
|
|
138
|
+
// channel-only routing.
|
|
139
|
+
const enriched = await Promise.all(
|
|
140
|
+
issues.map(async (issue) => {
|
|
141
|
+
try {
|
|
142
|
+
const detail = await sentryGetIssue(issue.id);
|
|
143
|
+
const sc = (detail?.suspectCommits || []).map((c) => ({
|
|
144
|
+
id: c.id,
|
|
145
|
+
shortId: c.shortId || (c.id ? String(c.id).slice(0, 7) : undefined),
|
|
146
|
+
message: c.message,
|
|
147
|
+
authorEmail: c.author?.email,
|
|
148
|
+
authorName: c.author?.name,
|
|
149
|
+
}));
|
|
150
|
+
return { ...issue, suspectCommits: sc };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.warn(` · suspectCommits fetch failed for ${issue.shortId || issue.id}: ${err.message}`);
|
|
153
|
+
return { ...issue, suspectCommits: [] };
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
const withAuthor = enriched.filter((i) => (i.suspectCommits || []).some((c) => c.authorEmail)).length;
|
|
158
|
+
console.log(`Hydrated suspectCommits — ${withAuthor}/${enriched.length} issue(s) have an identifiable author.`);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
issues: enriched,
|
|
162
|
+
fetchedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
},
|
|
52
165
|
};
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Hourly Sentry issue triage bot —
|
|
6
|
+
"description": "Hourly Sentry issue triage bot — LLM-classifies new issues by severity and pings Slack OR Lark for anything ≥ threshold.",
|
|
7
7
|
"main": "graph.mjs",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"test": "vitest run"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@zibby/core": "^0.5.1",
|
|
13
|
+
"@zibby/skills": "^0.1.26",
|
|
13
14
|
"zod": "^3.23.0"
|
|
14
15
|
},
|
|
15
16
|
"devDependencies": {
|
package/sentry-triage/state.js
CHANGED
|
@@ -1,76 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sentry-triage —
|
|
2
|
+
* sentry-triage — input + context schemas.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* issues from Sentry
|
|
7
|
-
* 2. filter_noise (custom execute) — drop known-noise patterns
|
|
8
|
-
* (browser-extension URLs, ResizeObserver loops, etc.) WITHOUT
|
|
9
|
-
* paying an LLM call per issue
|
|
10
|
-
* 3. classify (LLM) — classify the survivors as
|
|
11
|
-
* NOISE / LOW / MEDIUM / HIGH / CRITICAL with reasoning
|
|
12
|
-
* 4. dispatch_alerts (custom execute) — sub-graph dispatch to
|
|
13
|
-
* notify-slack OR notify-lark for issues above severityThreshold
|
|
4
|
+
* Trigger payload (inputSchema) is intentionally tiny: just sinceMinutes,
|
|
5
|
+
* the one per-run dial. Everything else is deploy-time ENV-tab config:
|
|
14
6
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
7
|
+
* Required (set ONE — at least one chat target):
|
|
8
|
+
* SLACK_CHANNEL channel id "C012345" or "#name"
|
|
9
|
+
* LARK_RECEIVE_ID oc_… chat id, ou_… open id, or email
|
|
10
|
+
*
|
|
11
|
+
* Optional:
|
|
12
|
+
* SEVERITY_THRESHOLD NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
|
|
13
|
+
* SLACK_MENTIONS JSON array — appended to CRITICAL Slack alerts only
|
|
14
|
+
* LARK_MENTIONS JSON array — appended to CRITICAL Lark alerts only
|
|
22
15
|
*/
|
|
23
16
|
|
|
24
17
|
import { z } from 'zod';
|
|
25
18
|
|
|
19
|
+
// Ordered low → high. Index doubles as severity rank.
|
|
26
20
|
export const SEVERITY_LEVELS = /** @type {const} */ (['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
|
27
21
|
|
|
22
|
+
/** True iff severity is at or above threshold per SEVERITY_LEVELS order. */
|
|
23
|
+
export function meetsSeverityThreshold(severity, threshold) {
|
|
24
|
+
const s = SEVERITY_LEVELS.indexOf(severity);
|
|
25
|
+
const t = SEVERITY_LEVELS.indexOf(threshold);
|
|
26
|
+
return (s === -1 ? 0 : s) >= (t === -1 ? SEVERITY_LEVELS.indexOf('MEDIUM') : t);
|
|
27
|
+
}
|
|
28
|
+
|
|
28
29
|
export const sentryTriageInputSchema = z.object({
|
|
29
|
-
// ── Sentry source ────────────────────────────────────────────────
|
|
30
|
-
organizationSlug: z.string().min(1)
|
|
31
|
-
.describe('Sentry organization slug (the URL segment after sentry.io/organizations/).'),
|
|
32
|
-
projectSlug: z.string().min(1)
|
|
33
|
-
.describe('Sentry project slug — limits triage to a single project.'),
|
|
34
|
-
environment: z.string().default('production')
|
|
35
|
-
.describe('Sentry environment tag to filter by (defaults to production).'),
|
|
36
30
|
sinceMinutes: z.number().int().min(5).max(1440).default(60)
|
|
37
|
-
.describe('
|
|
38
|
-
|
|
39
|
-
// ── Triage thresholds ────────────────────────────────────────────
|
|
40
|
-
severityThreshold: z.enum(SEVERITY_LEVELS).default('MEDIUM')
|
|
41
|
-
.describe('Only dispatch alerts for issues at or above this severity. Drop the rest.'),
|
|
42
|
-
maxIssues: z.number().int().min(1).max(100).default(20)
|
|
43
|
-
.describe('Cap issues processed per run. Protects against an unexpected error storm.'),
|
|
44
|
-
|
|
45
|
-
// ── Where to send alerts ────────────────────────────────────────
|
|
46
|
-
notifyWorker: z.enum(['notify-slack', 'notify-lark']).default('notify-slack')
|
|
47
|
-
.describe(
|
|
48
|
-
'Which child workflow to dispatch alerts to. Both must be deployed in the same project ' +
|
|
49
|
-
'as this triage workflow. Pick whichever messaging platform your team uses.',
|
|
50
|
-
),
|
|
51
|
-
|
|
52
|
-
// For notify-slack
|
|
53
|
-
slackChannel: z.string().min(1).max(120).optional()
|
|
54
|
-
.describe('Slack channel id (C012345) or #name. Required when notifyWorker=notify-slack.'),
|
|
55
|
-
slackMentions: z.array(z.string().max(60)).max(10).optional()
|
|
56
|
-
.describe('Mentions to append on CRITICAL alerts only, e.g. ["<!subteam^S0ONCALL>"].'),
|
|
57
|
-
|
|
58
|
-
// For notify-lark
|
|
59
|
-
larkReceiveId: z.string().min(1).max(120).optional()
|
|
60
|
-
.describe('Lark chat id (oc_…), open id (ou_…), or email. Required when notifyWorker=notify-lark.'),
|
|
61
|
-
larkMentions: z.array(z.string().max(200)).max(10).optional()
|
|
62
|
-
.describe('Lark @-mention strings for CRITICAL alerts.'),
|
|
63
|
-
|
|
64
|
-
model: z.string().default('auto')
|
|
65
|
-
.describe('LLM model override for classify_issues. Default auto-selects.'),
|
|
31
|
+
.describe('Lookback minutes (5–1440)'),
|
|
66
32
|
});
|
|
67
33
|
|
|
68
34
|
export const sentryTriageContextSchema = z.object({
|
|
69
|
-
// Runner-injected
|
|
70
35
|
workspace: z.string().optional()
|
|
71
|
-
.describe('Workspace path —
|
|
36
|
+
.describe('Workspace path — runner-injected; triage doesn\'t need it but graph.run requires it.'),
|
|
72
37
|
|
|
73
|
-
// Node outputs (mid-graph, keyed by node name)
|
|
74
38
|
fetch_issues: z.object({
|
|
75
39
|
issues: z.array(z.object({
|
|
76
40
|
id: z.string(),
|
|
@@ -93,14 +57,6 @@ export const sentryTriageContextSchema = z.object({
|
|
|
93
57
|
fetchedAt: z.string().optional(),
|
|
94
58
|
}).optional(),
|
|
95
59
|
|
|
96
|
-
filter_noise: z.object({
|
|
97
|
-
kept: z.array(z.any()),
|
|
98
|
-
dropped: z.array(z.object({
|
|
99
|
-
id: z.string(),
|
|
100
|
-
reason: z.string(),
|
|
101
|
-
})),
|
|
102
|
-
}).optional(),
|
|
103
|
-
|
|
104
60
|
classify: z.object({
|
|
105
61
|
classifications: z.array(z.object({
|
|
106
62
|
issueId: z.string(),
|
|
@@ -114,7 +70,11 @@ export const sentryTriageContextSchema = z.object({
|
|
|
114
70
|
|
|
115
71
|
dispatch_alerts: z.object({
|
|
116
72
|
dispatched: z.array(z.object({
|
|
117
|
-
issueId
|
|
73
|
+
// Deterministic dispatcher emits issueId; LLM batcher emits issueIds[].
|
|
74
|
+
// messageTs (Slack) and messageId (Lark) are both optional — only the
|
|
75
|
+
// variant that ran will populate one of them.
|
|
76
|
+
issueId: z.string().optional(),
|
|
77
|
+
issueIds: z.array(z.string()).optional(),
|
|
118
78
|
severity: z.enum(SEVERITY_LEVELS),
|
|
119
79
|
status: z.enum(['sent', 'skipped', 'failed']),
|
|
120
80
|
detail: z.string().optional(),
|
|
@@ -129,6 +89,3 @@ export const sentryTriageContextSchema = z.object({
|
|
|
129
89
|
}),
|
|
130
90
|
}).optional(),
|
|
131
91
|
});
|
|
132
|
-
|
|
133
|
-
export const sentryTriageStateSchema =
|
|
134
|
-
sentryTriageInputSchema.merge(sentryTriageContextSchema);
|