pm-slack 2026.6.2 → 2026.6.4
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/CHANGELOG.md +25 -1
- package/README.md +155 -12
- package/dist/index.d.ts +174 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +828 -54
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -13,9 +13,24 @@
|
|
|
13
13
|
* PM_SLACK_WEBHOOK (required) Slack incoming webhook URL
|
|
14
14
|
* PM_SLACK_CHANNEL (optional) Override channel, e.g. #pm-alerts
|
|
15
15
|
* PM_SLACK_MIN_PRIORITY (optional) Minimum priority to notify (1=critical … 4=low), default 1 (critical only; set 4 for all)
|
|
16
|
-
* PM_SLACK_EVENTS (optional) Comma-separated subset of hook events:
|
|
16
|
+
* PM_SLACK_EVENTS (optional) Comma-separated subset of hook events:
|
|
17
|
+
* create,close,block,cancel,open,start,unblock,reopen (default: all).
|
|
18
|
+
* Status-name aliases (e.g. "canceled","in_progress") are accepted.
|
|
19
|
+
* PM_SLACK_FORMAT (optional) Default notification format: "blockkit" (default) or "text"
|
|
20
|
+
* PM_SLACK_ASSIGNEE_MAP (optional) Comma list of "name=slackId" pairs mapping a pm item's
|
|
21
|
+
* assignee to a Slack @mention, e.g. "alice=U123,bob=U456".
|
|
22
|
+
* PM_SLACK_MENTION_ASSIGNEE (optional) Set to 0/false to disable assignee @mentions even when a map is set
|
|
23
|
+
* (mentions auto-enable in the hook whenever PM_SLACK_ASSIGNEE_MAP is non-empty).
|
|
24
|
+
* PM_SLACK_ROUTES (optional) JSON array of routing rules to send specific
|
|
25
|
+
* events/types/statuses to different webhooks/channels.
|
|
26
|
+
* Each rule: { "match": "<selector>", "webhook"?: "...", "channel"?: "..." }
|
|
27
|
+
* Selector forms: an event ("create"|"close"|"block"),
|
|
28
|
+
* "type:<itemType>" (case-insensitive), or "status:<status>".
|
|
29
|
+
* First matching rule wins; unset fields fall back to the defaults.
|
|
17
30
|
*/
|
|
18
31
|
import https from "node:https";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
19
34
|
const defineExtension = ((extension) => extension);
|
|
20
35
|
// ---------------------------------------------------------------------------
|
|
21
36
|
// EXIT_CODE / CommandError (re-implemented locally)
|
|
@@ -77,35 +92,181 @@ function readStrOption(options, key) {
|
|
|
77
92
|
// ---------------------------------------------------------------------------
|
|
78
93
|
// Event parsing
|
|
79
94
|
// ---------------------------------------------------------------------------
|
|
80
|
-
const ALL_EVENTS = [
|
|
95
|
+
const ALL_EVENTS = [
|
|
96
|
+
"create",
|
|
97
|
+
"close",
|
|
98
|
+
"block",
|
|
99
|
+
"cancel",
|
|
100
|
+
"open",
|
|
101
|
+
"start",
|
|
102
|
+
"unblock",
|
|
103
|
+
"reopen",
|
|
104
|
+
];
|
|
105
|
+
/**
|
|
106
|
+
* Friendly aliases accepted in PM_SLACK_EVENTS / `--on` so callers can write the
|
|
107
|
+
* status name they think in (e.g. "canceled"/"cancelled" → cancel,
|
|
108
|
+
* "in_progress" → start). Aliases normalize to a canonical EventKind.
|
|
109
|
+
*/
|
|
110
|
+
const EVENT_ALIASES = {
|
|
111
|
+
created: "create",
|
|
112
|
+
closed: "close",
|
|
113
|
+
done: "close",
|
|
114
|
+
complete: "close",
|
|
115
|
+
completed: "close",
|
|
116
|
+
resolved: "close",
|
|
117
|
+
blocked: "block",
|
|
118
|
+
canceled: "cancel",
|
|
119
|
+
cancelled: "cancel",
|
|
120
|
+
opened: "open",
|
|
121
|
+
in_progress: "start",
|
|
122
|
+
"in-progress": "start",
|
|
123
|
+
started: "start",
|
|
124
|
+
unblocked: "unblock",
|
|
125
|
+
reopened: "reopen",
|
|
126
|
+
};
|
|
127
|
+
function normalizeEvent(token) {
|
|
128
|
+
const t = token.trim().toLowerCase();
|
|
129
|
+
if (ALL_EVENTS.includes(t))
|
|
130
|
+
return t;
|
|
131
|
+
return EVENT_ALIASES[t] ?? null;
|
|
132
|
+
}
|
|
81
133
|
function parseEvents(spec) {
|
|
82
134
|
if (!spec)
|
|
83
135
|
return new Set(ALL_EVENTS);
|
|
84
136
|
const parsed = spec
|
|
85
137
|
.split(",")
|
|
86
|
-
.map((e) => e
|
|
87
|
-
.filter((e) =>
|
|
138
|
+
.map((e) => normalizeEvent(e))
|
|
139
|
+
.filter((e) => e !== null);
|
|
88
140
|
return parsed.length > 0 ? new Set(parsed) : new Set(ALL_EVENTS);
|
|
89
141
|
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Format parsing
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const ALL_FORMATS = ["blockkit", "text"];
|
|
146
|
+
/**
|
|
147
|
+
* Normalize a format spec to a known MessageFormat. Accepts a few friendly
|
|
148
|
+
* aliases ("block"/"blocks" → blockkit, "plain"/"txt" → text). Falls back to
|
|
149
|
+
* the supplied default when the spec is empty or unrecognized.
|
|
150
|
+
*/
|
|
151
|
+
function parseFormat(spec, fallback = "blockkit") {
|
|
152
|
+
if (!spec)
|
|
153
|
+
return fallback;
|
|
154
|
+
const v = spec.trim().toLowerCase();
|
|
155
|
+
if (v === "blockkit" || v === "block" || v === "blocks" || v === "rich")
|
|
156
|
+
return "blockkit";
|
|
157
|
+
if (v === "text" || v === "plain" || v === "txt")
|
|
158
|
+
return "text";
|
|
159
|
+
return fallback;
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Routing
|
|
163
|
+
//
|
|
164
|
+
// PM_SLACK_ROUTES is an optional JSON array of rules that send specific events,
|
|
165
|
+
// item types, or statuses to a different webhook/channel. Routing is purely
|
|
166
|
+
// additive: with no rules configured, behavior is identical to before.
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
function parseRoutes(spec) {
|
|
169
|
+
if (!spec || !spec.trim())
|
|
170
|
+
return [];
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = JSON.parse(spec);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
console.error("[pm-slack] PM_SLACK_ROUTES is not valid JSON — routing disabled");
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(raw)) {
|
|
180
|
+
console.error("[pm-slack] PM_SLACK_ROUTES must be a JSON array — routing disabled");
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const rules = [];
|
|
184
|
+
for (const entry of raw) {
|
|
185
|
+
if (!entry || typeof entry !== "object")
|
|
186
|
+
continue;
|
|
187
|
+
const e = entry;
|
|
188
|
+
const match = typeof e.match === "string" ? e.match.trim() : "";
|
|
189
|
+
if (!match)
|
|
190
|
+
continue;
|
|
191
|
+
const webhook = typeof e.webhook === "string" && e.webhook.trim() ? e.webhook.trim() : undefined;
|
|
192
|
+
const channel = typeof e.channel === "string" && e.channel.trim() ? e.channel.trim() : undefined;
|
|
193
|
+
if (!webhook && !channel)
|
|
194
|
+
continue; // a rule with no override is meaningless
|
|
195
|
+
rules.push({ match, webhook, channel });
|
|
196
|
+
}
|
|
197
|
+
return rules;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Does a single route rule match this event/item? Selector forms:
|
|
201
|
+
* - bare event name: "create" | "close" | "block"
|
|
202
|
+
* - "type:<itemType>" (case-insensitive on item.type)
|
|
203
|
+
* - "status:<status>" (case-insensitive on item.status)
|
|
204
|
+
* - "*" / "all" (matches everything — useful as a catch-all)
|
|
205
|
+
*/
|
|
206
|
+
function ruleMatches(rule, event, item) {
|
|
207
|
+
const sel = rule.match.toLowerCase();
|
|
208
|
+
if (sel === "*" || sel === "all")
|
|
209
|
+
return true;
|
|
210
|
+
if (sel.startsWith("type:")) {
|
|
211
|
+
return (item.type ?? "").toLowerCase() === sel.slice("type:".length).trim();
|
|
212
|
+
}
|
|
213
|
+
if (sel.startsWith("status:")) {
|
|
214
|
+
return (item.status ?? "").toLowerCase() === sel.slice("status:".length).trim();
|
|
215
|
+
}
|
|
216
|
+
return sel === event;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the destination (webhook + channel) for an event/item given the
|
|
220
|
+
* configured routes and the default webhook/channel. First matching rule wins;
|
|
221
|
+
* any field a rule omits falls back to the default. Returns null only when no
|
|
222
|
+
* webhook can be resolved at all.
|
|
223
|
+
*/
|
|
224
|
+
function selectRoute(event, item, routes, defaultWebhook, defaultChannel) {
|
|
225
|
+
for (const rule of routes) {
|
|
226
|
+
if (ruleMatches(rule, event, item)) {
|
|
227
|
+
const webhookUrl = rule.webhook ?? defaultWebhook;
|
|
228
|
+
if (!webhookUrl)
|
|
229
|
+
return null;
|
|
230
|
+
return { webhookUrl, channel: rule.channel ?? defaultChannel };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!defaultWebhook)
|
|
234
|
+
return null;
|
|
235
|
+
return { webhookUrl: defaultWebhook, channel: defaultChannel };
|
|
236
|
+
}
|
|
90
237
|
function loadConfig() {
|
|
91
238
|
const webhookUrl = process.env.PM_SLACK_WEBHOOK ?? "";
|
|
92
|
-
|
|
239
|
+
const routes = parseRoutes(process.env.PM_SLACK_ROUTES);
|
|
240
|
+
// A default webhook is not strictly required if routing rules carry their own
|
|
241
|
+
// webhook(s); only bail out when there is no webhook source at all.
|
|
242
|
+
const routesHaveWebhook = routes.some((r) => r.webhook);
|
|
243
|
+
if (!webhookUrl && !routesHaveWebhook) {
|
|
93
244
|
console.error("[pm-slack] PM_SLACK_WEBHOOK not set — notifications disabled");
|
|
94
245
|
return null;
|
|
95
246
|
}
|
|
96
|
-
// Validate URL
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
247
|
+
// Validate the default URL when present (route webhooks are validated lazily).
|
|
248
|
+
if (webhookUrl) {
|
|
249
|
+
try {
|
|
250
|
+
new URL(webhookUrl);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
console.error("[pm-slack] PM_SLACK_WEBHOOK is not a valid URL — notifications disabled");
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
103
256
|
}
|
|
104
257
|
const channel = process.env.PM_SLACK_CHANNEL?.trim() || undefined;
|
|
105
258
|
const rawPriority = parseInt(process.env.PM_SLACK_MIN_PRIORITY ?? "1", 10);
|
|
106
259
|
const minPriority = rawPriority >= 1 && rawPriority <= 4 ? rawPriority : 1;
|
|
107
260
|
const events = parseEvents(process.env.PM_SLACK_EVENTS);
|
|
108
|
-
|
|
261
|
+
const format = parseFormat(process.env.PM_SLACK_FORMAT);
|
|
262
|
+
const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
|
|
263
|
+
// Mentions are auto-enabled in the hook whenever a non-empty map is present;
|
|
264
|
+
// PM_SLACK_MENTION_ASSIGNEE=0/false can force them off even with a map.
|
|
265
|
+
const mentionEnv = (process.env.PM_SLACK_MENTION_ASSIGNEE ?? "").trim().toLowerCase();
|
|
266
|
+
const mentionAssignee = mentionEnv === "0" || mentionEnv === "false" || mentionEnv === "no" || mentionEnv === "off"
|
|
267
|
+
? false
|
|
268
|
+
: assigneeMap.size > 0;
|
|
269
|
+
return { webhookUrl, channel, minPriority, events, format, routes, assigneeMap, mentionAssignee };
|
|
109
270
|
}
|
|
110
271
|
// ---------------------------------------------------------------------------
|
|
111
272
|
// Priority helpers
|
|
@@ -126,6 +287,89 @@ function meetsMinPriority(item, minPriority) {
|
|
|
126
287
|
return item.priority <= minPriority;
|
|
127
288
|
}
|
|
128
289
|
// ---------------------------------------------------------------------------
|
|
290
|
+
// Assignee → Slack @mention mapping
|
|
291
|
+
//
|
|
292
|
+
// PM_SLACK_ASSIGNEE_MAP maps a pm item's assignee to a Slack user/group id so
|
|
293
|
+
// notifications can @-mention the responsible person. Format is a comma-list of
|
|
294
|
+
// `name=id` pairs, e.g. PM_SLACK_ASSIGNEE_MAP="alice=U123,bob=U456". Slack
|
|
295
|
+
// renders <@U123> as a user mention and <!subteam^S123> for groups; we accept a
|
|
296
|
+
// raw id (wrapped as <@id>) or a pre-wrapped token (kept verbatim). Mapping is
|
|
297
|
+
// opt-in and additive — when unset, output is byte-identical to before.
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
/** Parse PM_SLACK_ASSIGNEE_MAP into a case-insensitive name→id lookup. */
|
|
300
|
+
function parseAssigneeMap(spec) {
|
|
301
|
+
const map = new Map();
|
|
302
|
+
if (!spec || !spec.trim())
|
|
303
|
+
return map;
|
|
304
|
+
for (const pair of spec.split(",")) {
|
|
305
|
+
const eq = pair.indexOf("=");
|
|
306
|
+
if (eq <= 0)
|
|
307
|
+
continue;
|
|
308
|
+
const name = pair.slice(0, eq).trim().toLowerCase();
|
|
309
|
+
const id = pair.slice(eq + 1).trim();
|
|
310
|
+
if (name && id)
|
|
311
|
+
map.set(name, id);
|
|
312
|
+
}
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Resolve an item's assignee to a Slack mention token using the map. Returns a
|
|
317
|
+
* ready-to-embed mention string (e.g. `<@U123>`) or undefined when there's no
|
|
318
|
+
* assignee or no mapping. A raw id is wrapped as `<@id>`; a token already
|
|
319
|
+
* containing `<` is passed through unchanged (supports `<!subteam^S123>` etc).
|
|
320
|
+
*/
|
|
321
|
+
function resolveAssigneeMention(item, map) {
|
|
322
|
+
const assignee = item.assignee?.trim();
|
|
323
|
+
if (!assignee || map.size === 0)
|
|
324
|
+
return undefined;
|
|
325
|
+
const id = map.get(assignee.toLowerCase());
|
|
326
|
+
if (!id)
|
|
327
|
+
return undefined;
|
|
328
|
+
return id.startsWith("<") ? id : `<@${id}>`;
|
|
329
|
+
}
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Item URL extraction (for Block Kit action buttons)
|
|
332
|
+
//
|
|
333
|
+
// pm has no canonical URL field, so read a defensive set of common field names
|
|
334
|
+
// off the item; the first valid http(s) URL wins. Used to render an "Open item"
|
|
335
|
+
// / "View on GitHub" action button in the Block Kit notification (Feature 3).
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
function isHttpUrl(value) {
|
|
338
|
+
if (typeof value !== "string")
|
|
339
|
+
return false;
|
|
340
|
+
const v = value.trim();
|
|
341
|
+
if (!v)
|
|
342
|
+
return false;
|
|
343
|
+
try {
|
|
344
|
+
const u = new URL(v);
|
|
345
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Resolve a primary URL for the item, plus whether it points at GitHub. */
|
|
352
|
+
function resolveItemUrl(item, override) {
|
|
353
|
+
const candidates = [
|
|
354
|
+
override,
|
|
355
|
+
item.github_url,
|
|
356
|
+
item.githubUrl,
|
|
357
|
+
item.html_url,
|
|
358
|
+
item.htmlUrl,
|
|
359
|
+
item.url,
|
|
360
|
+
item.source_url,
|
|
361
|
+
item.sourceUrl,
|
|
362
|
+
item.link,
|
|
363
|
+
];
|
|
364
|
+
for (const c of candidates) {
|
|
365
|
+
if (isHttpUrl(c)) {
|
|
366
|
+
const url = c.trim();
|
|
367
|
+
return { url, isGithub: /(^|\.)github\.com$/i.test(new URL(url).hostname) };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
129
373
|
// Plain-text message formatting (fallback)
|
|
130
374
|
// ---------------------------------------------------------------------------
|
|
131
375
|
function itemTypeLabel(item) {
|
|
@@ -135,26 +379,47 @@ const EVENT_META = {
|
|
|
135
379
|
create: { verb: "created", emoji: "🆕" },
|
|
136
380
|
close: { verb: "closed", emoji: "✅" },
|
|
137
381
|
block: { verb: "is blocked", emoji: "🚫" },
|
|
382
|
+
cancel: { verb: "canceled", emoji: "🛑" },
|
|
383
|
+
open: { verb: "opened", emoji: "📂" },
|
|
384
|
+
start: { verb: "started", emoji: "🔄" },
|
|
385
|
+
unblock: { verb: "unblocked", emoji: "🔓" },
|
|
386
|
+
reopen: { verb: "reopened", emoji: "♻️" },
|
|
138
387
|
};
|
|
139
388
|
function eventReason(item, event) {
|
|
140
389
|
if (event === "close") {
|
|
141
390
|
return item.close_reason?.trim() || item.closedReason?.trim() || undefined;
|
|
142
391
|
}
|
|
392
|
+
if (event === "cancel") {
|
|
393
|
+
// pm persists the cancel reason in close_reason; honor a dedicated
|
|
394
|
+
// cancel_reason first if a future pm version introduces one.
|
|
395
|
+
return (item.cancel_reason?.trim() ||
|
|
396
|
+
item.cancelReason?.trim() ||
|
|
397
|
+
item.close_reason?.trim() ||
|
|
398
|
+
item.closedReason?.trim() ||
|
|
399
|
+
undefined);
|
|
400
|
+
}
|
|
143
401
|
if (event === "block") {
|
|
144
402
|
return item.blocked_reason?.trim() || item.blockedReason?.trim() || undefined;
|
|
145
403
|
}
|
|
146
404
|
return undefined;
|
|
147
405
|
}
|
|
148
|
-
function buildTextMessage(item, event, channel) {
|
|
406
|
+
function buildTextMessage(item, event, channel, mention) {
|
|
149
407
|
const type = itemTypeLabel(item);
|
|
150
408
|
const meta = EVENT_META[event];
|
|
151
409
|
let msg = `*[${type}]* ${item.title} ${meta.verb} ${meta.emoji}`;
|
|
152
410
|
if (event === "create") {
|
|
153
411
|
msg += `\nPriority: ${priorityLabel(item.priority)} • Type: ${type} • By: ${item.author ?? "unknown"}`;
|
|
154
412
|
}
|
|
155
|
-
else {
|
|
413
|
+
else if (event === "close" || event === "cancel" || event === "block") {
|
|
414
|
+
// Terminal/blocked transitions carry a reason field.
|
|
156
415
|
msg += `\nReason: ${eventReason(item, event) ?? "no reason given"}`;
|
|
157
416
|
}
|
|
417
|
+
else {
|
|
418
|
+
// Status transitions without a reason (open/start/unblock/reopen).
|
|
419
|
+
msg += `\nStatus: ${item.status ?? meta.verb}${item.assignee ? ` • Assignee: ${item.assignee}` : ""}`;
|
|
420
|
+
}
|
|
421
|
+
if (mention)
|
|
422
|
+
msg += `\nAssignee: ${mention}`;
|
|
158
423
|
if (channel)
|
|
159
424
|
msg += `\n_Channel: ${channel}_`;
|
|
160
425
|
return msg;
|
|
@@ -178,6 +443,12 @@ function buildItemBlockKit(item, event, opts = {}) {
|
|
|
178
443
|
fields.push({ type: "mrkdwn", text: `*Status:*\n${item.status}` });
|
|
179
444
|
if (item.author)
|
|
180
445
|
fields.push({ type: "mrkdwn", text: `*By:*\n${item.author}` });
|
|
446
|
+
// Assignee mention (Feature 2): show the @mention when mapped, else the raw
|
|
447
|
+
// assignee name so the field is still useful without a configured map.
|
|
448
|
+
if (opts.mention)
|
|
449
|
+
fields.push({ type: "mrkdwn", text: `*Assignee:*\n${opts.mention}` });
|
|
450
|
+
else if (item.assignee)
|
|
451
|
+
fields.push({ type: "mrkdwn", text: `*Assignee:*\n${item.assignee}` });
|
|
181
452
|
blocks.push({ type: "section", fields });
|
|
182
453
|
const reason = eventReason(item, event);
|
|
183
454
|
if (reason) {
|
|
@@ -192,6 +463,24 @@ function buildItemBlockKit(item, event, opts = {}) {
|
|
|
192
463
|
text: { type: "mrkdwn", text: opts.note },
|
|
193
464
|
});
|
|
194
465
|
}
|
|
466
|
+
// Action buttons (Feature 3): a link button to the item / GitHub URL when one
|
|
467
|
+
// is present on the item. Block Kit "actions" with a url-bearing button needs
|
|
468
|
+
// no interactivity backend — it opens the URL directly. Plain-text path is
|
|
469
|
+
// unaffected (this only runs for the blockkit format).
|
|
470
|
+
if (opts.link) {
|
|
471
|
+
const label = opts.link.isGithub ? "View on GitHub" : "Open item";
|
|
472
|
+
blocks.push({
|
|
473
|
+
type: "actions",
|
|
474
|
+
elements: [
|
|
475
|
+
{
|
|
476
|
+
type: "button",
|
|
477
|
+
text: { type: "plain_text", text: label, emoji: true },
|
|
478
|
+
url: opts.link.url,
|
|
479
|
+
action_id: opts.link.isGithub ? "pm_slack_open_github" : "pm_slack_open_item",
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
});
|
|
483
|
+
}
|
|
195
484
|
const footerBits = [
|
|
196
485
|
`pm item ${item.id} ${meta.verb}`,
|
|
197
486
|
opts.channel ? `channel ${opts.channel}` : null,
|
|
@@ -200,9 +489,32 @@ function buildItemBlockKit(item, event, opts = {}) {
|
|
|
200
489
|
type: "context",
|
|
201
490
|
elements: [{ type: "mrkdwn", text: `🤖 pm-slack · ${footerBits.join(" · ")}` }],
|
|
202
491
|
});
|
|
203
|
-
const fallback = buildTextMessage(item, event, opts.channel);
|
|
492
|
+
const fallback = buildTextMessage(item, event, opts.channel, opts.mention);
|
|
204
493
|
return { blocks, fallback };
|
|
205
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Build a ready-to-post SlackPayload for an item event in the requested format.
|
|
497
|
+
* - "blockkit": rich Block Kit blocks + plain-text fallback in `text`.
|
|
498
|
+
* - "text": plain mrkdwn only (no `blocks`), for minimal/legacy channels.
|
|
499
|
+
*/
|
|
500
|
+
function buildItemPayload(item, event, format, opts = {}) {
|
|
501
|
+
if (format === "text") {
|
|
502
|
+
const text = buildTextMessage(item, event, opts.channel, opts.mention);
|
|
503
|
+
const body = opts.note && opts.note.trim() ? `${text}\n${opts.note.trim()}` : text;
|
|
504
|
+
return {
|
|
505
|
+
text: body,
|
|
506
|
+
mrkdwn: true,
|
|
507
|
+
...(opts.channel ? { channel: opts.channel } : {}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const { blocks, fallback } = buildItemBlockKit(item, event, opts);
|
|
511
|
+
return {
|
|
512
|
+
text: fallback,
|
|
513
|
+
blocks,
|
|
514
|
+
mrkdwn: true,
|
|
515
|
+
...(opts.channel ? { channel: opts.channel } : {}),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
206
518
|
// ---------------------------------------------------------------------------
|
|
207
519
|
// Slack HTTP POST
|
|
208
520
|
// ---------------------------------------------------------------------------
|
|
@@ -246,6 +558,245 @@ function postToSlack(webhookUrl, payload) {
|
|
|
246
558
|
});
|
|
247
559
|
}
|
|
248
560
|
// ---------------------------------------------------------------------------
|
|
561
|
+
// Digest: reading the pm store + activity aggregation
|
|
562
|
+
//
|
|
563
|
+
// The digest reads items directly from the pm store (`<pm_root>/<dir>/*.toon`
|
|
564
|
+
// and `*.json`) using a tiny scalar parser — we only need a handful of
|
|
565
|
+
// top-level fields (id/title/type/status/priority/timestamps/reasons). This
|
|
566
|
+
// avoids a runtime dependency on the pm SDK service layer (registerService is
|
|
567
|
+
// limited and corrupts output, #96) and keeps the package dependency-free.
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
/** Directories under a pm root that hold items, by convention. */
|
|
570
|
+
const ITEM_DIRS = ["tasks", "features", "issues", "epics", "stories", "bugs", "decisions", "items"];
|
|
571
|
+
/**
|
|
572
|
+
* Minimal parser for a stored pm item file. Handles the toon scalar form
|
|
573
|
+
* (`key: value`, optionally quoted) and JSON. Only top-level scalar fields are
|
|
574
|
+
* extracted; nested/array sections are ignored. Returns null when no id found.
|
|
575
|
+
*/
|
|
576
|
+
function parseStoredItem(content, ext) {
|
|
577
|
+
if (ext === ".json") {
|
|
578
|
+
try {
|
|
579
|
+
const obj = JSON.parse(content);
|
|
580
|
+
const node = (obj.item ?? obj);
|
|
581
|
+
if (typeof node.id !== "string")
|
|
582
|
+
return null;
|
|
583
|
+
return node;
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// toon: line-oriented `key: value`. Stop reading a key when it introduces a
|
|
590
|
+
// block/array (e.g. `notes[1]{...}:`); we only want flat scalars.
|
|
591
|
+
const out = {};
|
|
592
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
593
|
+
const line = rawLine;
|
|
594
|
+
if (!line || /^\s/.test(line))
|
|
595
|
+
continue; // skip indented (nested) lines
|
|
596
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
|
|
597
|
+
if (!m)
|
|
598
|
+
continue;
|
|
599
|
+
const key = m[1];
|
|
600
|
+
let value = m[2].trim();
|
|
601
|
+
if (value === "" || value === '""') {
|
|
602
|
+
out[key] = "";
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// Strip surrounding double quotes (toon quotes strings with embedded chars).
|
|
606
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
607
|
+
value = value.slice(1, -1).replace(/\\"/g, '"');
|
|
608
|
+
}
|
|
609
|
+
out[key] = value;
|
|
610
|
+
}
|
|
611
|
+
if (typeof out.id !== "string")
|
|
612
|
+
return null;
|
|
613
|
+
if (typeof out.priority === "string" && /^\d+$/.test(out.priority)) {
|
|
614
|
+
out.priority = parseInt(out.priority, 10);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
/** Read all items from a pm root. Best-effort: unreadable files are skipped. */
|
|
619
|
+
function readStoreItems(pmRoot) {
|
|
620
|
+
const items = [];
|
|
621
|
+
for (const dir of ITEM_DIRS) {
|
|
622
|
+
const full = path.join(pmRoot, dir);
|
|
623
|
+
let entries;
|
|
624
|
+
try {
|
|
625
|
+
entries = fs.readdirSync(full);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
for (const name of entries) {
|
|
631
|
+
const ext = path.extname(name);
|
|
632
|
+
if (ext !== ".toon" && ext !== ".json")
|
|
633
|
+
continue;
|
|
634
|
+
try {
|
|
635
|
+
const content = fs.readFileSync(path.join(full, name), "utf8");
|
|
636
|
+
const item = parseStoredItem(content, ext);
|
|
637
|
+
if (item)
|
|
638
|
+
items.push(item);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// skip unreadable item
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return items;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Resolve a `--since <date>` / `--days <n>` window into an epoch-ms cutoff.
|
|
649
|
+
* `since` (ISO date or datetime) wins when both are given; otherwise `days`
|
|
650
|
+
* back from `now`. Defaults to 7 days. Returns the cutoff in ms.
|
|
651
|
+
*/
|
|
652
|
+
function resolveWindow(since, days, now = Date.now()) {
|
|
653
|
+
if (since) {
|
|
654
|
+
const t = Date.parse(since);
|
|
655
|
+
if (!Number.isNaN(t)) {
|
|
656
|
+
return { cutoffMs: t, label: `since ${since}` };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const d = days !== undefined && Number.isFinite(days) && days > 0 ? Math.floor(days) : 7;
|
|
660
|
+
return { cutoffMs: now - d * 24 * 60 * 60 * 1000, label: `last ${d} day${d === 1 ? "" : "s"}` };
|
|
661
|
+
}
|
|
662
|
+
function statusIsClosed(status) {
|
|
663
|
+
const s = (status ?? "").toLowerCase();
|
|
664
|
+
return s === "closed" || s === "done" || s === "resolved" || s === "complete" || s === "completed";
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Aggregate store items into digest buckets for the window. An item counts as:
|
|
668
|
+
* - created if created_at >= cutoff
|
|
669
|
+
* - closed if it is in a closed/done status AND updated_at >= cutoff
|
|
670
|
+
* - blocked if status === blocked AND updated_at >= cutoff
|
|
671
|
+
* - in_progress if status === in_progress AND updated_at >= cutoff
|
|
672
|
+
* An item may appear in multiple buckets (e.g. created and blocked).
|
|
673
|
+
*/
|
|
674
|
+
function aggregateDigest(items, cutoffMs, windowLabel) {
|
|
675
|
+
const buckets = {
|
|
676
|
+
created: [],
|
|
677
|
+
closed: [],
|
|
678
|
+
blocked: [],
|
|
679
|
+
in_progress: [],
|
|
680
|
+
};
|
|
681
|
+
for (const item of items) {
|
|
682
|
+
const created = item.created_at ? Date.parse(item.created_at) : NaN;
|
|
683
|
+
const updated = item.updated_at ? Date.parse(item.updated_at) : created;
|
|
684
|
+
const status = (item.status ?? "").toLowerCase();
|
|
685
|
+
if (!Number.isNaN(created) && created >= cutoffMs)
|
|
686
|
+
buckets.created.push(item);
|
|
687
|
+
if (statusIsClosed(status) && !Number.isNaN(updated) && updated >= cutoffMs)
|
|
688
|
+
buckets.closed.push(item);
|
|
689
|
+
if (status === "blocked" && !Number.isNaN(updated) && updated >= cutoffMs)
|
|
690
|
+
buckets.blocked.push(item);
|
|
691
|
+
if (status === "in_progress" && !Number.isNaN(updated) && updated >= cutoffMs)
|
|
692
|
+
buckets.in_progress.push(item);
|
|
693
|
+
}
|
|
694
|
+
const counts = {
|
|
695
|
+
created: buckets.created.length,
|
|
696
|
+
closed: buckets.closed.length,
|
|
697
|
+
blocked: buckets.blocked.length,
|
|
698
|
+
in_progress: buckets.in_progress.length,
|
|
699
|
+
};
|
|
700
|
+
const total = counts.created + counts.closed + counts.blocked + counts.in_progress;
|
|
701
|
+
return { windowLabel, cutoffMs, counts, buckets, total };
|
|
702
|
+
}
|
|
703
|
+
const DIGEST_BUCKET_META = {
|
|
704
|
+
created: { label: "Created", emoji: "🆕" },
|
|
705
|
+
closed: { label: "Closed", emoji: "✅" },
|
|
706
|
+
blocked: { label: "Blocked", emoji: "🚫" },
|
|
707
|
+
in_progress: { label: "In progress", emoji: "🔄" },
|
|
708
|
+
};
|
|
709
|
+
const DIGEST_ORDER = ["created", "closed", "in_progress", "blocked"];
|
|
710
|
+
/** A short "id title" line per item, capped so the digest stays readable. */
|
|
711
|
+
function digestItemLines(items, max = 5) {
|
|
712
|
+
const lines = items.slice(0, max).map((i) => `• ${i.id} — ${i.title ?? "(untitled)"}`);
|
|
713
|
+
if (items.length > max)
|
|
714
|
+
lines.push(`• …and ${items.length - max} more`);
|
|
715
|
+
return lines;
|
|
716
|
+
}
|
|
717
|
+
function buildDigestText(summary, channel) {
|
|
718
|
+
const head = `*pm activity digest* (${summary.windowLabel}) — ${summary.total} update${summary.total === 1 ? "" : "s"}`;
|
|
719
|
+
const parts = [head];
|
|
720
|
+
for (const bucket of DIGEST_ORDER) {
|
|
721
|
+
const items = summary.buckets[bucket];
|
|
722
|
+
if (items.length === 0)
|
|
723
|
+
continue;
|
|
724
|
+
const meta = DIGEST_BUCKET_META[bucket];
|
|
725
|
+
parts.push(`\n${meta.emoji} *${meta.label}* (${items.length})`);
|
|
726
|
+
parts.push(digestItemLines(items).join("\n"));
|
|
727
|
+
}
|
|
728
|
+
if (summary.total === 0)
|
|
729
|
+
parts.push("\n_No activity in this window._");
|
|
730
|
+
if (channel)
|
|
731
|
+
parts.push(`\n_Channel: ${channel}_`);
|
|
732
|
+
return parts.join("\n");
|
|
733
|
+
}
|
|
734
|
+
function buildDigestBlockKit(summary, channel) {
|
|
735
|
+
const blocks = [];
|
|
736
|
+
blocks.push({
|
|
737
|
+
type: "header",
|
|
738
|
+
text: { type: "plain_text", text: `📊 pm activity digest`, emoji: true },
|
|
739
|
+
});
|
|
740
|
+
blocks.push({
|
|
741
|
+
type: "context",
|
|
742
|
+
elements: [
|
|
743
|
+
{
|
|
744
|
+
type: "mrkdwn",
|
|
745
|
+
text: `${summary.windowLabel} · ${summary.total} update${summary.total === 1 ? "" : "s"}`,
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
});
|
|
749
|
+
// Counts grid.
|
|
750
|
+
const fields = DIGEST_ORDER.map((bucket) => {
|
|
751
|
+
const meta = DIGEST_BUCKET_META[bucket];
|
|
752
|
+
return { type: "mrkdwn", text: `${meta.emoji} *${meta.label}:*\n${summary.counts[bucket]}` };
|
|
753
|
+
});
|
|
754
|
+
blocks.push({ type: "section", fields });
|
|
755
|
+
for (const bucket of DIGEST_ORDER) {
|
|
756
|
+
const items = summary.buckets[bucket];
|
|
757
|
+
if (items.length === 0)
|
|
758
|
+
continue;
|
|
759
|
+
const meta = DIGEST_BUCKET_META[bucket];
|
|
760
|
+
blocks.push({ type: "divider" });
|
|
761
|
+
blocks.push({
|
|
762
|
+
type: "section",
|
|
763
|
+
text: {
|
|
764
|
+
type: "mrkdwn",
|
|
765
|
+
text: `${meta.emoji} *${meta.label}* (${items.length})\n${digestItemLines(items).join("\n")}`,
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (summary.total === 0) {
|
|
770
|
+
blocks.push({
|
|
771
|
+
type: "section",
|
|
772
|
+
text: { type: "mrkdwn", text: "_No activity in this window._" },
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
const footerBits = [`pm digest · ${summary.windowLabel}`, channel ? `channel ${channel}` : null].filter(Boolean);
|
|
776
|
+
blocks.push({
|
|
777
|
+
type: "context",
|
|
778
|
+
elements: [{ type: "mrkdwn", text: `🤖 pm-slack · ${footerBits.join(" · ")}` }],
|
|
779
|
+
});
|
|
780
|
+
const fallback = buildDigestText(summary, channel);
|
|
781
|
+
return { blocks, fallback };
|
|
782
|
+
}
|
|
783
|
+
function buildDigestPayload(summary, format, channel) {
|
|
784
|
+
if (format === "text") {
|
|
785
|
+
return {
|
|
786
|
+
text: buildDigestText(summary, channel),
|
|
787
|
+
mrkdwn: true,
|
|
788
|
+
...(channel ? { channel } : {}),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const { blocks, fallback } = buildDigestBlockKit(summary, channel);
|
|
792
|
+
return {
|
|
793
|
+
text: fallback,
|
|
794
|
+
blocks,
|
|
795
|
+
mrkdwn: true,
|
|
796
|
+
...(channel ? { channel } : {}),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
249
800
|
// Event detection (afterCommand hook)
|
|
250
801
|
// ---------------------------------------------------------------------------
|
|
251
802
|
/** Commands that create a new item */
|
|
@@ -254,23 +805,74 @@ const CREATE_COMMANDS = new Set(["add", "create", "new"]);
|
|
|
254
805
|
const CLOSE_COMMANDS = new Set(["close", "done", "complete", "finish", "resolve"]);
|
|
255
806
|
/** Commands that update an item's status or attributes */
|
|
256
807
|
const UPDATE_COMMANDS = new Set(["update", "edit", "set", "status"]);
|
|
808
|
+
/** Lifecycle-alias commands that move an item to in_progress (start). */
|
|
809
|
+
const START_COMMANDS = new Set(["claim", "start-task", "start", "begin"]);
|
|
810
|
+
/** Lifecycle-alias commands that move an item back to open (e.g. pause). */
|
|
811
|
+
const OPEN_COMMANDS = new Set(["pause-task", "release", "reopen", "publish"]);
|
|
812
|
+
/** Map a (normalized) status string to the EventKind for a transition into it. */
|
|
813
|
+
function statusToEvent(status) {
|
|
814
|
+
switch ((status ?? "").toLowerCase()) {
|
|
815
|
+
case "blocked":
|
|
816
|
+
return "block";
|
|
817
|
+
case "canceled":
|
|
818
|
+
case "cancelled":
|
|
819
|
+
return "cancel";
|
|
820
|
+
case "closed":
|
|
821
|
+
case "done":
|
|
822
|
+
case "resolved":
|
|
823
|
+
case "complete":
|
|
824
|
+
case "completed":
|
|
825
|
+
return "close";
|
|
826
|
+
case "in_progress":
|
|
827
|
+
case "in-progress":
|
|
828
|
+
return "start";
|
|
829
|
+
case "open":
|
|
830
|
+
return "open";
|
|
831
|
+
default:
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Detect the lifecycle event for a completed command. Strategy:
|
|
837
|
+
* 1. Create/close commands map directly.
|
|
838
|
+
* 2. Status-changing commands (update/set/lifecycle aliases) derive the event
|
|
839
|
+
* from the *resulting* status (preferring the explicit --status option,
|
|
840
|
+
* falling back to the result item's status).
|
|
841
|
+
* 3. When the result carries the prior status (`previousStatus`), refine
|
|
842
|
+
* open→reopen (from a closed/canceled state) and open→unblock (from
|
|
843
|
+
* blocked) so terminal-recovery transitions are distinguishable.
|
|
844
|
+
*/
|
|
257
845
|
function detectEvent(ctx) {
|
|
258
846
|
const cmd = ctx.command?.toLowerCase() ?? "";
|
|
259
847
|
if (CREATE_COMMANDS.has(cmd))
|
|
260
848
|
return "create";
|
|
261
849
|
if (CLOSE_COMMANDS.has(cmd))
|
|
262
850
|
return "close";
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
851
|
+
const result = ctx.result;
|
|
852
|
+
const resultStatus = result?.item?.status;
|
|
853
|
+
const prevStatus = (result?.previousStatus ?? result?.previous_status ?? "").toLowerCase();
|
|
854
|
+
// Lifecycle aliases imply a target status even without --status.
|
|
855
|
+
let event = null;
|
|
856
|
+
if (START_COMMANDS.has(cmd))
|
|
857
|
+
event = "start";
|
|
858
|
+
else if (OPEN_COMMANDS.has(cmd))
|
|
859
|
+
event = cmd === "reopen" ? "reopen" : "open";
|
|
860
|
+
if (!event && UPDATE_COMMANDS.has(cmd)) {
|
|
861
|
+
const optStatus = ctx.options?.["status"];
|
|
862
|
+
event = statusToEvent(optStatus) ?? statusToEvent(resultStatus);
|
|
272
863
|
}
|
|
273
|
-
|
|
864
|
+
if (!event)
|
|
865
|
+
return null;
|
|
866
|
+
// Refine an "open" transition using the prior status when available:
|
|
867
|
+
// blocked -> open => unblock
|
|
868
|
+
// closed/canceled -> open => reopen
|
|
869
|
+
if (event === "open" && prevStatus) {
|
|
870
|
+
if (prevStatus === "blocked")
|
|
871
|
+
return "unblock";
|
|
872
|
+
if (["closed", "canceled", "cancelled", "done", "resolved"].includes(prevStatus))
|
|
873
|
+
return "reopen";
|
|
874
|
+
}
|
|
875
|
+
return event;
|
|
274
876
|
}
|
|
275
877
|
function extractItem(ctx) {
|
|
276
878
|
const result = ctx.result;
|
|
@@ -289,7 +891,7 @@ function extractItem(ctx) {
|
|
|
289
891
|
// ---------------------------------------------------------------------------
|
|
290
892
|
export default defineExtension({
|
|
291
893
|
name: "pm-slack",
|
|
292
|
-
version: "2026.6.
|
|
894
|
+
version: "2026.6.4",
|
|
293
895
|
activate(api) {
|
|
294
896
|
// -----------------------------------------------------------------------
|
|
295
897
|
// afterCommand hook — fires after every pm-cli command completes.
|
|
@@ -322,15 +924,22 @@ export default defineExtension({
|
|
|
322
924
|
console.error(`[pm-slack] Item priority ${item.priority} below minimum ${config.minPriority} — skipping`);
|
|
323
925
|
return;
|
|
324
926
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
927
|
+
// Resolve destination via routing rules (falls back to defaults).
|
|
928
|
+
const target = selectRoute(event, item, config.routes, config.webhookUrl, config.channel);
|
|
929
|
+
if (!target) {
|
|
930
|
+
console.error(`[pm-slack] No webhook resolved for event "${event}" — skipping`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const mention = config.mentionAssignee
|
|
934
|
+
? resolveAssigneeMention(item, config.assigneeMap)
|
|
935
|
+
: undefined;
|
|
936
|
+
const link = resolveItemUrl(item);
|
|
937
|
+
const payload = buildItemPayload(item, event, config.format, {
|
|
938
|
+
channel: target.channel,
|
|
939
|
+
mention,
|
|
940
|
+
link,
|
|
333
941
|
});
|
|
942
|
+
await postToSlack(target.webhookUrl, payload);
|
|
334
943
|
console.error(`[pm-slack] Notification sent for event "${event}" on item ${item.id}`);
|
|
335
944
|
}
|
|
336
945
|
catch (err) {
|
|
@@ -386,9 +995,14 @@ export default defineExtension({
|
|
|
386
995
|
{ long: "--title", value_name: "title", description: "Title shown in the Block Kit header (defaults to the --text first line)" },
|
|
387
996
|
{ long: "--channel", value_name: "name", description: "Channel name shown in the message (e.g. #pm-alerts). Overrides PM_SLACK_CHANNEL." },
|
|
388
997
|
{ long: "--thread", value_name: "ts", description: "Slack thread timestamp (thread_ts) to reply into a thread" },
|
|
389
|
-
{ long: "--on", value_name: "events", description: "Comma list of lifecycle events for the message template (create,close,block). Default: create" },
|
|
998
|
+
{ long: "--on", value_name: "events", description: "Comma list of lifecycle events for the message template (create,close,block,cancel,open,start,unblock,reopen). Default: create" },
|
|
999
|
+
{ long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
|
|
1000
|
+
{ long: "--assignee", value_name: "name", description: "Assignee name shown on the message (mapped to a Slack @mention via PM_SLACK_ASSIGNEE_MAP)" },
|
|
1001
|
+
{ long: "--mention-assignee", description: "Resolve the assignee to a Slack @mention using PM_SLACK_ASSIGNEE_MAP" },
|
|
1002
|
+
{ long: "--url", value_name: "url", description: "Add a Block Kit action button linking to this URL (e.g. a GitHub item)" },
|
|
390
1003
|
{ long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK env var)" },
|
|
391
|
-
{ long: "--dry-run", description: "Print the message and
|
|
1004
|
+
{ long: "--dry-run", description: "Print the message and payload without posting to Slack" },
|
|
1005
|
+
{ long: "--json", description: "Emit the result/payload as JSON (machine-readable)" },
|
|
392
1006
|
],
|
|
393
1007
|
async run(ctx) {
|
|
394
1008
|
const options = ctx.options ?? {};
|
|
@@ -396,6 +1010,8 @@ export default defineExtension({
|
|
|
396
1010
|
const webhookUrl = readStrOption(options, "webhook") ?? process.env.PM_SLACK_WEBHOOK ?? "";
|
|
397
1011
|
const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
|
|
398
1012
|
const threadTs = readStrOption(options, "thread");
|
|
1013
|
+
const asJson = ctx.global?.json === true || readBoolOption(options, "json");
|
|
1014
|
+
const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
|
|
399
1015
|
// `--on` selects the message template. First event wins for the header verb.
|
|
400
1016
|
const events = parseEvents(readStrOption(options, "on") ?? "create");
|
|
401
1017
|
const event = (ALL_EVENTS.find((e) => events.has(e)) ?? "create");
|
|
@@ -404,27 +1020,35 @@ export default defineExtension({
|
|
|
404
1020
|
if (!title) {
|
|
405
1021
|
throw new CommandError("Provide a --title or --text to send (e.g. `pm slack notify --text 'hello'`)", EXIT_CODE.USAGE);
|
|
406
1022
|
}
|
|
407
|
-
// Synthesize a minimal item from the flags so we can reuse the
|
|
408
|
-
//
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1023
|
+
// Synthesize a minimal item from the flags so we can reuse the
|
|
1024
|
+
// message builder. This command is for ad-hoc posts, not item lookups.
|
|
1025
|
+
const assignee = readStrOption(options, "assignee");
|
|
1026
|
+
const url = readStrOption(options, "url");
|
|
1027
|
+
const item = { id: "manual", title, type: "Note", ...(assignee ? { assignee } : {}) };
|
|
1028
|
+
const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
|
|
1029
|
+
// Mention when explicitly requested, or implicitly when an assignee
|
|
1030
|
+
// maps to a Slack id (so the @mention is opt-in but ergonomic).
|
|
1031
|
+
const wantMention = readBoolOption(options, "mention-assignee") || assigneeMap.size > 0;
|
|
1032
|
+
const mention = wantMention ? resolveAssigneeMention(item, assigneeMap) : undefined;
|
|
1033
|
+
const link = resolveItemUrl(item, url);
|
|
414
1034
|
const payload = {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
1035
|
+
...buildItemPayload(item, event, format, {
|
|
1036
|
+
channel,
|
|
1037
|
+
note: text && text !== title ? text : undefined,
|
|
1038
|
+
mention,
|
|
1039
|
+
link,
|
|
1040
|
+
}),
|
|
419
1041
|
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
420
1042
|
};
|
|
421
1043
|
if (dryRun) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1044
|
+
if (!asJson) {
|
|
1045
|
+
console.error("--- DRY RUN (message not posted) ---");
|
|
1046
|
+
process.stdout.write(payload.text + "\n");
|
|
1047
|
+
console.error(`--- ${format} payload ---`);
|
|
1048
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
1049
|
+
console.error("--- END ---");
|
|
1050
|
+
}
|
|
1051
|
+
return { dryRun: true, event, format, channel, thread_ts: threadTs, payload };
|
|
428
1052
|
}
|
|
429
1053
|
if (!webhookUrl) {
|
|
430
1054
|
// Graceful no-op: warn and exit 0 so a missing webhook never blocks
|
|
@@ -445,17 +1069,167 @@ export default defineExtension({
|
|
|
445
1069
|
return { posted: true, event, channel, thread_ts: threadTs };
|
|
446
1070
|
},
|
|
447
1071
|
});
|
|
1072
|
+
// ---------------------------------------------------------------------
|
|
1073
|
+
// command — `pm slack test` : build and PRINT a sample notification in
|
|
1074
|
+
// the chosen format WITHOUT posting. Fully offline; the package is
|
|
1075
|
+
// testable with no Slack credentials. Honors --json for machine reads.
|
|
1076
|
+
// ---------------------------------------------------------------------
|
|
1077
|
+
api.registerCommand({
|
|
1078
|
+
name: "slack test",
|
|
1079
|
+
description: "Preview a sample pm-slack notification in the chosen format without posting (offline)",
|
|
1080
|
+
intent: "Preview Slack notification formatting offline (no webhook, no network)",
|
|
1081
|
+
examples: [
|
|
1082
|
+
"pm slack test --format blockkit",
|
|
1083
|
+
"pm slack test --format text --on close",
|
|
1084
|
+
"pm slack test --on block --json",
|
|
1085
|
+
"pm slack test --on cancel",
|
|
1086
|
+
"PM_SLACK_ASSIGNEE_MAP='alice=U123' pm slack test --on start --mention-assignee",
|
|
1087
|
+
],
|
|
1088
|
+
flags: [
|
|
1089
|
+
{ long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
|
|
1090
|
+
{ long: "--on", value_name: "event", description: "Lifecycle event to preview (create,close,block,cancel,open,start,unblock,reopen). Default: create" },
|
|
1091
|
+
{ long: "--title", value_name: "title", description: "Sample item title (default: a representative example)" },
|
|
1092
|
+
{ long: "--channel", value_name: "name", description: "Channel hint to show in the preview (overrides PM_SLACK_CHANNEL)" },
|
|
1093
|
+
{ long: "--mention-assignee", description: "Resolve the sample assignee to a Slack @mention via PM_SLACK_ASSIGNEE_MAP" },
|
|
1094
|
+
{ long: "--url", value_name: "url", description: "Add a Block Kit action button linking to this URL (default: a sample GitHub URL)" },
|
|
1095
|
+
{ long: "--json", description: "Emit the preview payload as JSON" },
|
|
1096
|
+
{ long: "--dry-run", description: "Accepted for symmetry; this command never posts." },
|
|
1097
|
+
],
|
|
1098
|
+
async run(ctx) {
|
|
1099
|
+
const options = ctx.options ?? {};
|
|
1100
|
+
// `--json` is consumed by pm as a global flag, so prefer global.json;
|
|
1101
|
+
// also honor a local --json for robustness across runtimes.
|
|
1102
|
+
const asJson = ctx.global?.json === true || readBoolOption(options, "json");
|
|
1103
|
+
const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
|
|
1104
|
+
const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
|
|
1105
|
+
const events = parseEvents(readStrOption(options, "on") ?? "create");
|
|
1106
|
+
const event = (ALL_EVENTS.find((e) => events.has(e)) ?? "create");
|
|
1107
|
+
const t = readStrOption(options, "title");
|
|
1108
|
+
// A representative sample item per event so the preview is realistic.
|
|
1109
|
+
const sample = {
|
|
1110
|
+
create: { id: "pm-1a2b", title: t ?? "Add OAuth login flow", type: "Feature", priority: 2, status: "open", author: "alice", assignee: "alice", github_url: "https://github.com/unbraind/pm-slack/issues/1" },
|
|
1111
|
+
close: { id: "pm-3c4d", title: t ?? "Fix login redirect bug", type: "Issue", priority: 1, status: "closed", author: "bob", assignee: "bob", close_reason: "Fixed in commit abc123", github_url: "https://github.com/unbraind/pm-slack/issues/2" },
|
|
1112
|
+
block: { id: "pm-5e6f", title: t ?? "Dashboard redesign", type: "Epic", priority: 2, status: "blocked", author: "carol", assignee: "carol", blocked_reason: "Waiting on design approval" },
|
|
1113
|
+
cancel: { id: "pm-7g8h", title: t ?? "Legacy export pipeline", type: "Task", priority: 3, status: "canceled", author: "dave", assignee: "dave", close_reason: "Superseded by new pipeline" },
|
|
1114
|
+
open: { id: "pm-9i0j", title: t ?? "Publish onboarding guide", type: "Task", priority: 3, status: "open", author: "erin", assignee: "erin" },
|
|
1115
|
+
start: { id: "pm-1k2l", title: t ?? "Implement rate limiter", type: "Feature", priority: 2, status: "in_progress", author: "frank", assignee: "frank", github_url: "https://github.com/unbraind/pm-slack/pull/3" },
|
|
1116
|
+
unblock: { id: "pm-3m4n", title: t ?? "Payment reconciliation", type: "Issue", priority: 1, status: "open", author: "grace", assignee: "grace" },
|
|
1117
|
+
reopen: { id: "pm-5o6p", title: t ?? "Flaky integration test", type: "Bug", priority: 2, status: "open", author: "heidi", assignee: "heidi" },
|
|
1118
|
+
};
|
|
1119
|
+
const item = sample[event];
|
|
1120
|
+
const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
|
|
1121
|
+
const wantMention = readBoolOption(options, "mention-assignee") || assigneeMap.size > 0;
|
|
1122
|
+
const mention = wantMention ? resolveAssigneeMention(item, assigneeMap) : undefined;
|
|
1123
|
+
const link = resolveItemUrl(item, readStrOption(options, "url"));
|
|
1124
|
+
const payload = buildItemPayload(item, event, format, { channel, mention, link });
|
|
1125
|
+
// In JSON mode, pm renders the returned object — don't also write to
|
|
1126
|
+
// stdout (that would corrupt the JSON stream). In human mode, print a
|
|
1127
|
+
// readable preview to stdout/stderr.
|
|
1128
|
+
if (!asJson) {
|
|
1129
|
+
console.error(`--- PREVIEW (${format}, event=${event}) — nothing posted ---`);
|
|
1130
|
+
process.stdout.write(payload.text + "\n");
|
|
1131
|
+
console.error(`--- ${format} payload ---`);
|
|
1132
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
1133
|
+
console.error("--- END ---");
|
|
1134
|
+
}
|
|
1135
|
+
return { preview: true, event, format, channel, payload };
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
// ---------------------------------------------------------------------
|
|
1139
|
+
// command — `pm slack digest` : summarize recent activity (created /
|
|
1140
|
+
// closed / blocked / in-progress) over a --since/--days window into a
|
|
1141
|
+
// single notification. Reads the pm store directly; --dry-run prints
|
|
1142
|
+
// without posting.
|
|
1143
|
+
// ---------------------------------------------------------------------
|
|
1144
|
+
api.registerCommand({
|
|
1145
|
+
name: "slack digest",
|
|
1146
|
+
description: "Post (or preview) a single summary of recent pm activity over a time window",
|
|
1147
|
+
intent: "Summarize recent pm activity (created/closed/blocked/in-progress) into one Slack message",
|
|
1148
|
+
examples: [
|
|
1149
|
+
"pm slack digest --days 7 --dry-run",
|
|
1150
|
+
"pm slack digest --since 2026-06-01 --format text --dry-run",
|
|
1151
|
+
"pm slack digest --days 1 --format blockkit --json --dry-run",
|
|
1152
|
+
],
|
|
1153
|
+
flags: [
|
|
1154
|
+
{ long: "--since", value_name: "date", description: "Only include activity at/after this ISO date (e.g. 2026-06-01)" },
|
|
1155
|
+
{ long: "--days", value_name: "n", description: "Look back N days from now (default 7; ignored when --since is given)" },
|
|
1156
|
+
{ long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
|
|
1157
|
+
{ long: "--channel", value_name: "name", description: "Channel to post to (overrides PM_SLACK_CHANNEL)" },
|
|
1158
|
+
{ long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK)" },
|
|
1159
|
+
{ long: "--dry-run", description: "Build and print the digest without posting to Slack" },
|
|
1160
|
+
{ long: "--json", description: "Emit the digest summary/payload as JSON" },
|
|
1161
|
+
],
|
|
1162
|
+
async run(ctx) {
|
|
1163
|
+
const options = ctx.options ?? {};
|
|
1164
|
+
const dryRun = readBoolOption(options, "dry-run");
|
|
1165
|
+
const asJson = ctx.global?.json === true || readBoolOption(options, "json");
|
|
1166
|
+
const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
|
|
1167
|
+
const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
|
|
1168
|
+
const webhookUrl = readStrOption(options, "webhook") ?? process.env.PM_SLACK_WEBHOOK ?? "";
|
|
1169
|
+
const since = readStrOption(options, "since");
|
|
1170
|
+
const daysStr = readStrOption(options, "days");
|
|
1171
|
+
const days = daysStr !== undefined ? parseInt(daysStr, 10) : undefined;
|
|
1172
|
+
if (daysStr !== undefined && (days === undefined || Number.isNaN(days) || days <= 0)) {
|
|
1173
|
+
throw new CommandError(`--days must be a positive integer (got "${daysStr}")`, EXIT_CODE.USAGE);
|
|
1174
|
+
}
|
|
1175
|
+
const pmRoot = ctx.pm_root ?? path.join(process.cwd(), ".agents", "pm");
|
|
1176
|
+
const { cutoffMs, label } = resolveWindow(since, days);
|
|
1177
|
+
const items = readStoreItems(pmRoot);
|
|
1178
|
+
const summary = aggregateDigest(items, cutoffMs, label);
|
|
1179
|
+
const payload = buildDigestPayload(summary, format, channel);
|
|
1180
|
+
if (dryRun) {
|
|
1181
|
+
if (!asJson) {
|
|
1182
|
+
console.error(`--- DRY RUN digest (${format}, ${label}) — nothing posted ---`);
|
|
1183
|
+
process.stdout.write(payload.text + "\n");
|
|
1184
|
+
console.error(`--- ${format} payload ---`);
|
|
1185
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
1186
|
+
console.error("--- END ---");
|
|
1187
|
+
}
|
|
1188
|
+
return { dryRun: true, format, channel, counts: summary.counts, total: summary.total, window: label, payload };
|
|
1189
|
+
}
|
|
1190
|
+
// Real post requested: a webhook is mandatory. Fail cleanly (exit 1).
|
|
1191
|
+
if (!webhookUrl) {
|
|
1192
|
+
throw new CommandError("No Slack webhook configured: set PM_SLACK_WEBHOOK or pass --webhook (or use --dry-run to preview).", EXIT_CODE.GENERIC_FAILURE);
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
await postToSlack(webhookUrl, payload);
|
|
1196
|
+
}
|
|
1197
|
+
catch (err) {
|
|
1198
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1199
|
+
throw new CommandError(`Failed to post digest to Slack: ${message}`, EXIT_CODE.GENERIC_FAILURE);
|
|
1200
|
+
}
|
|
1201
|
+
return { posted: true, format, channel, counts: summary.counts, total: summary.total, window: label };
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
448
1204
|
}
|
|
449
1205
|
},
|
|
450
1206
|
});
|
|
451
1207
|
// Exported for unit tests (Block Kit builder + helpers).
|
|
452
1208
|
export const __test__ = {
|
|
453
1209
|
buildItemBlockKit,
|
|
1210
|
+
buildItemPayload,
|
|
454
1211
|
buildTextMessage,
|
|
455
1212
|
parseEvents,
|
|
1213
|
+
normalizeEvent,
|
|
1214
|
+
parseFormat,
|
|
1215
|
+
parseRoutes,
|
|
1216
|
+
ruleMatches,
|
|
1217
|
+
selectRoute,
|
|
456
1218
|
detectEvent,
|
|
1219
|
+
statusToEvent,
|
|
457
1220
|
extractItem,
|
|
458
1221
|
meetsMinPriority,
|
|
1222
|
+
parseAssigneeMap,
|
|
1223
|
+
resolveAssigneeMention,
|
|
1224
|
+
resolveItemUrl,
|
|
1225
|
+
isHttpUrl,
|
|
1226
|
+
EVENT_META,
|
|
1227
|
+
parseStoredItem,
|
|
1228
|
+
resolveWindow,
|
|
1229
|
+
aggregateDigest,
|
|
1230
|
+
buildDigestText,
|
|
1231
|
+
buildDigestBlockKit,
|
|
1232
|
+
buildDigestPayload,
|
|
459
1233
|
EXIT_CODE,
|
|
460
1234
|
CommandError,
|
|
461
1235
|
};
|