@zibby/workflow-templates 0.7.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zibby/workflow-templates",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, notify-notion, sentry-triage.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -72,7 +72,7 @@
72
72
  "dependencies": {
73
73
  "@anthropic-ai/sdk": "^0.88.0",
74
74
  "@zibby/agent-workflow": "^0.4.2",
75
- "@zibby/skills": "^0.1.25",
75
+ "@zibby/skills": "^0.1.27",
76
76
  "axios": "^1.15.0",
77
77
  "handlebars": "^4.7.9",
78
78
  "zod": "^3.23.0 || ^4.0.0"
@@ -48,7 +48,17 @@ export class SentryTriageAgent extends WorkflowAgent {
48
48
  graph.addNode('dispatch_alerts', dispatchNode);
49
49
 
50
50
  graph.setEntryPoint('fetch_issues');
51
- graph.addEdge('fetch_issues', 'classify');
51
+ // Short-circuit when Sentry returned nothing for this window. The
52
+ // empty-list case is the common idle path (steady-state apps don't
53
+ // throw new errors every hour), and running classify + dispatch on
54
+ // an empty input wastes two Claude calls per run — at hourly cadence
55
+ // across many tenants that adds up. Cleaner to route directly to END
56
+ // at the graph level than to short-circuit inside each downstream
57
+ // node's prompt (which still spends a model round-trip).
58
+ graph.addConditionalEdges('fetch_issues', (state) => {
59
+ const issues = state?.fetch_issues?.issues || [];
60
+ return issues.length === 0 ? 'END' : 'classify';
61
+ });
52
62
  graph.addEdge('classify', 'dispatch_alerts');
53
63
  graph.addEdge('dispatch_alerts', 'END');
54
64
 
@@ -1,41 +1,80 @@
1
1
  /**
2
- * dispatch_alerts node — LLM-driven dispatcher.
2
+ * dispatch_alerts node — LLM-driven dispatcher with author routing.
3
3
  *
4
- * The agent sees ALL classified issues + their full data and makes
5
- * judgment calls before calling the chat tool:
6
- * - Bulk related issues into ONE message (5 errors in /checkout/ →
7
- * "⚠️ Checkout spike: 5 errors, top: ...").
8
- * - De-dupe near-duplicates ("seen 3 times, same culprit").
9
- * - Honor SEVERITY_THRESHOLD (skip anything below).
10
- * - Attach mentions only on CRITICAL.
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:
11
7
  *
12
- * Provider routing: chatNotifySkill.resolve() picks the slack OR lark
13
- * MCP server based on which ENV var is set, so the LLM only ever sees
14
- * ONE provider's tools (slack_* or lark_*) it can't accidentally
15
- * call the wrong one.
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.).
16
13
  *
17
- * Reliability: outputSchema enforces a `dispatched` record per
18
- * group + summary counts. A malformed LLM response triggers a retry
19
- * with the schema embedded.
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
20
23
  *
21
- * ENV tab config:
22
- * SLACK_CHANNEL OR LARK_RECEIVE_ID — required, pick one
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:
23
50
  * SEVERITY_THRESHOLD — NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
24
- * SLACK_MENTIONS OR LARK_MENTIONS JSON array, optional, CRITICAL only
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
25
55
  */
26
56
 
27
57
  import { z, SKILLS } from '@zibby/core';
28
58
  import { SEVERITY_LEVELS } from '../state.js';
29
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
+
30
76
  const DispatchAlertsOutputSchema = z.object({
31
- dispatched: z.array(z.object({
32
- issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
33
- severity: z.enum(SEVERITY_LEVELS),
34
- status: z.enum(['sent', 'skipped', 'failed']),
35
- messageTs: z.string().optional(), // Slack
36
- messageId: z.string().optional(), // Lark
37
- detail: z.string().optional(),
38
- })),
77
+ dispatched: z.array(DispatchedRecordSchema),
39
78
  summary: z.object({
40
79
  total: z.number().describe('Number of messages POSTED (not issues — batched groups count as 1).'),
41
80
  sent: z.number(),
@@ -44,77 +83,157 @@ const DispatchAlertsOutputSchema = z.object({
44
83
  }),
45
84
  });
46
85
 
86
+ const SEVERITY_ORDER = ['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
87
+
47
88
  const DISPATCH_PROMPT = (state = {}) => {
48
89
  const issues = state?.fetch_issues?.issues || [];
49
90
  const classifications = state?.classify?.classifications || [];
50
91
 
51
- const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
52
- const slackChannel = process.env.SLACK_CHANNEL || '';
53
- const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
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
+ }
54
121
 
55
- let provider, toolName, recipientLine, mentionsRaw;
122
+ // ── Provider selection ──────────────────────────────────────────
123
+ let provider, postTool, lookupTool, channelId, mentionsRaw;
56
124
  if (slackChannel) {
57
- provider = 'slack';
58
- toolName = 'slack_post_message';
59
- recipientLine = `Post every message to Slack channel: ${JSON.stringify(slackChannel)}\nCall: slack_post_message({ channel: "${slackChannel}", text: "…" })`;
125
+ provider = 'slack';
126
+ postTool = 'slack_post_message';
127
+ lookupTool = 'slack_lookup_user_by_email';
128
+ channelId = slackChannel;
60
129
  mentionsRaw = process.env.SLACK_MENTIONS || '[]';
61
130
  } else if (larkReceiveId) {
62
- provider = 'lark';
63
- toolName = 'lark_send_message';
64
- recipientLine = `Post every message to Lark receive_id: ${JSON.stringify(larkReceiveId)}\nCall: lark_send_message({ receive_id: "${larkReceiveId}", text: "…" })`;
131
+ provider = 'lark';
132
+ postTool = 'lark_send_message';
133
+ lookupTool = 'lark_lookup_user_by_email';
134
+ channelId = larkReceiveId;
65
135
  mentionsRaw = process.env.LARK_MENTIONS || '[]';
66
136
  } else {
67
- throw new Error('sentry-triage: configure SLACK_CHANNEL (for Slack) or LARK_RECEIVE_ID (for Lark) in the ENV tab.');
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
+ );
68
144
  }
69
145
 
70
146
  let mentions;
71
147
  try { mentions = JSON.parse(mentionsRaw); } catch { mentions = []; }
72
148
  if (!Array.isArray(mentions)) mentions = [];
73
149
 
74
- return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${toolName}** tool.
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');
75
185
 
76
- # Recipient
77
- ${recipientLine}
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
+ : '';
78
189
 
79
- # Severity threshold
80
- Skip any issue below: ${threshold}
81
- (Severity order, low → high: ${SEVERITY_LEVELS.join(' < ')})
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).
82
192
 
83
- # Mentions
84
- CRITICAL messages only — prepend: ${JSON.stringify(mentions.join(' '))}
85
- HIGH/MEDIUM/LOW — no mentions.
193
+ # Default routing policy
194
+ ${policyBlock}${overrideBlock}
86
195
 
87
- # Your judgment
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)
88
205
  - Batch issues with the same culprit / metadata.filename into ONE message.
89
- - De-dupe near-duplicates (e.g. same error text in different paths). Mention "seen N times".
90
- - Keep each message short. Lead with severity in *[BRACKETS]*. Include the Sentry permalink so the on-call can click through.
206
+ - De-dupe near-duplicates ("seen N times"). Keep messages short. Lead with severity in *[BRACKETS]*. Include Sentry permalinks.
91
207
 
92
- # Message format (template, adapt as needed)
208
+ # Message format
93
209
  \`\`\`
94
210
  *[CRITICAL]* TypeError: Cannot read 'id' of undefined
95
211
  12 users hit /checkout — likely regression on r1234.
96
212
  📍 handleCheckout(checkout.ts) · 47 events
213
+ Suspect commit: a1b2c3d4 by sarah@acme.com — "refactor checkout state"
97
214
  https://sentry.io/.../1234/
98
215
  \`\`\`
99
216
 
100
217
  # Output (outputSchema-enforced)
101
-
102
- Return ONE record per ${toolName} call you actually made (or skipped/failed).
103
- \`issueIds\` is an array — for batched messages it carries every issue in the group.
104
- \`severity\` is the highest severity in the group.
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.
105
219
 
106
220
  \`\`\`json
107
221
  {
108
222
  "dispatched": [
109
- { "issueIds": ["1", "5", "7"], "severity": "CRITICAL", "status": "sent"${provider === 'slack' ? ', "messageTs": "1716109330.555"' : ', "messageId": "om_xxxxx"'} }
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
+ }
110
229
  ],
111
230
  "summary": { "total": 1, "sent": 1, "skipped": 0, "failed": 0 }
112
231
  }
113
232
  \`\`\`
114
233
 
115
- # Issues + classifications
234
+ # Issues + classifications + suspect commits
116
235
 
117
- Each entry below has the Sentry issue plus the classifier's verdict + reasoning. Use both.
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.
118
237
 
119
238
  \`\`\`json
120
239
  ${JSON.stringify(
@@ -128,10 +247,10 @@ ${JSON.stringify(
128
247
  \`\`\`
129
248
 
130
249
  # Rules
131
- - Skip below-threshold issues silently (just include them in dispatched with status="skipped"; no chat call).
132
- - DON'T invent severities or issue IDs. Use what's given.
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.
133
252
  - DON'T post more messages than necessary. If 5 issues are clearly one bug, post 1 message.
134
- - DO post if in doubt — under-paging is worse than over-paging for triage.
253
+ - DO post if in doubt — under-paging is worse than over-paging.
135
254
  `;
136
255
  };
137
256
 
@@ -26,7 +26,21 @@
26
26
 
27
27
  import { z } from 'zod';
28
28
  import { SKILLS } from '@zibby/core';
29
- import { sentryListIssues } from '@zibby/skills/sentry';
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
+ });
30
44
 
31
45
  const IssueShape = z.object({
32
46
  id: z.string(),
@@ -45,6 +59,11 @@ const IssueShape = z.object({
45
59
  value: z.string().optional(),
46
60
  filename: z.string().optional(),
47
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(),
48
67
  });
49
68
 
50
69
  const FetchIssuesOutputSchema = z.object({
@@ -65,10 +84,24 @@ export const fetchIssuesNode = {
65
84
  : context;
66
85
 
67
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`);
68
97
 
69
98
  const issues = await sentryListIssues({
70
- query: `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`,
71
- sort: 'created',
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',
72
105
  // 100 issues is the practical ceiling for a triage notification.
73
106
  // Beyond that, classify+dispatch lose signal — a "deluge" digest
74
107
  // tells the user nothing actionable. If a customer regularly
@@ -77,8 +110,55 @@ export const fetchIssuesNode = {
77
110
  limit: 100,
78
111
  });
79
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
+
80
160
  return {
81
- issues,
161
+ issues: enriched,
82
162
  fetchedAt: new Date().toISOString(),
83
163
  };
84
164
  },
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@zibby/core": "^0.5.1",
13
- "@zibby/skills": "^0.1.25",
13
+ "@zibby/skills": "^0.1.26",
14
14
  "zod": "^3.23.0"
15
15
  },
16
16
  "devDependencies": {