pm-slack-standup 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 +24 -1
- package/README.md +70 -14
- package/dist/index.d.ts +229 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +595 -125
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const EXIT_CODE = {
|
|
|
18
18
|
USAGE: 2,
|
|
19
19
|
NOT_FOUND: 3,
|
|
20
20
|
};
|
|
21
|
-
class CommandError extends Error {
|
|
21
|
+
export class CommandError extends Error {
|
|
22
22
|
exitCode;
|
|
23
23
|
constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
|
|
24
24
|
super(message);
|
|
@@ -26,6 +26,12 @@ class CommandError extends Error {
|
|
|
26
26
|
this.exitCode = exitCode;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
export const ALL_SECTIONS = [
|
|
30
|
+
"in_progress",
|
|
31
|
+
"blocked",
|
|
32
|
+
"done",
|
|
33
|
+
"up_next",
|
|
34
|
+
];
|
|
29
35
|
// ---------------------------------------------------------------------------
|
|
30
36
|
// Option helpers
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
@@ -37,7 +43,7 @@ class CommandError extends Error {
|
|
|
37
43
|
function camelCase(key) {
|
|
38
44
|
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
39
45
|
}
|
|
40
|
-
function readBoolOption(options, key) {
|
|
46
|
+
export function readBoolOption(options, key) {
|
|
41
47
|
for (const candidate of [key, camelCase(key)]) {
|
|
42
48
|
const value = options[candidate];
|
|
43
49
|
if (typeof value === "boolean")
|
|
@@ -52,7 +58,7 @@ function readBoolOption(options, key) {
|
|
|
52
58
|
}
|
|
53
59
|
return false;
|
|
54
60
|
}
|
|
55
|
-
function readStrOption(options, key) {
|
|
61
|
+
export function readStrOption(options, key) {
|
|
56
62
|
for (const candidate of [key, camelCase(key)]) {
|
|
57
63
|
const value = options[candidate];
|
|
58
64
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
@@ -66,7 +72,7 @@ function readStrOption(options, key) {
|
|
|
66
72
|
* Accepts `author=@handle,other=@h2` (commas) or semicolon separators.
|
|
67
73
|
* A leading `@` on the handle is optional and normalized on.
|
|
68
74
|
*/
|
|
69
|
-
function parseMentionMap(spec) {
|
|
75
|
+
export function parseMentionMap(spec) {
|
|
70
76
|
const map = {};
|
|
71
77
|
if (!spec)
|
|
72
78
|
return map;
|
|
@@ -84,6 +90,176 @@ function parseMentionMap(spec) {
|
|
|
84
90
|
}
|
|
85
91
|
return map;
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Normalize a `--format` value. Accepts the four public formats plus the
|
|
95
|
+
* legacy `text` alias (== `plain`). Unknown values raise a USAGE CommandError.
|
|
96
|
+
*/
|
|
97
|
+
export function parseFormat(raw) {
|
|
98
|
+
if (raw == null)
|
|
99
|
+
return "slack";
|
|
100
|
+
const v = raw.trim().toLowerCase();
|
|
101
|
+
if (v === "" || v === "slack")
|
|
102
|
+
return "slack";
|
|
103
|
+
if (v === "blockkit" || v === "block-kit" || v === "blocks")
|
|
104
|
+
return "blockkit";
|
|
105
|
+
if (v === "markdown" || v === "md")
|
|
106
|
+
return "markdown";
|
|
107
|
+
if (v === "plain" || v === "text" || v === "txt")
|
|
108
|
+
return "plain";
|
|
109
|
+
throw new CommandError(`Unknown --format '${raw}'. Valid: slack | blockkit | markdown | plain.`, EXIT_CODE.USAGE);
|
|
110
|
+
}
|
|
111
|
+
export function parseGroupBy(raw) {
|
|
112
|
+
if (raw == null)
|
|
113
|
+
return "status";
|
|
114
|
+
const v = raw.trim().toLowerCase();
|
|
115
|
+
if (v === "" || v === "status")
|
|
116
|
+
return "status";
|
|
117
|
+
if (v === "assignee" || v === "owner")
|
|
118
|
+
return "assignee";
|
|
119
|
+
if (v === "sprint")
|
|
120
|
+
return "sprint";
|
|
121
|
+
if (v === "type")
|
|
122
|
+
return "type";
|
|
123
|
+
throw new CommandError(`Unknown --group-by '${raw}'. Valid: status | assignee | sprint | type.`, EXIT_CODE.USAGE);
|
|
124
|
+
}
|
|
125
|
+
const SECTION_ALIASES = {
|
|
126
|
+
in_progress: "in_progress",
|
|
127
|
+
"in-progress": "in_progress",
|
|
128
|
+
wip: "in_progress",
|
|
129
|
+
progress: "in_progress",
|
|
130
|
+
blocked: "blocked",
|
|
131
|
+
done: "done",
|
|
132
|
+
closed: "done",
|
|
133
|
+
up_next: "up_next",
|
|
134
|
+
"up-next": "up_next",
|
|
135
|
+
upnext: "up_next",
|
|
136
|
+
next: "up_next",
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Parse a `--sections` spec (comma/semicolon list) into an ordered, de-duped
|
|
140
|
+
* list of section keys. Empty spec β all sections in default order. An
|
|
141
|
+
* unknown token is a USAGE error rather than a silent drop.
|
|
142
|
+
*/
|
|
143
|
+
export function parseSections(spec) {
|
|
144
|
+
if (!spec || !spec.trim())
|
|
145
|
+
return [...ALL_SECTIONS];
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const raw of spec.split(/[,;]/)) {
|
|
148
|
+
const token = raw.trim().toLowerCase();
|
|
149
|
+
if (!token)
|
|
150
|
+
continue;
|
|
151
|
+
const key = SECTION_ALIASES[token];
|
|
152
|
+
if (!key) {
|
|
153
|
+
throw new CommandError(`Unknown --sections value '${raw.trim()}'. Valid: in_progress | blocked | done | up_next.`, EXIT_CODE.USAGE);
|
|
154
|
+
}
|
|
155
|
+
if (!out.includes(key))
|
|
156
|
+
out.push(key);
|
|
157
|
+
}
|
|
158
|
+
return out.length > 0 ? out : [...ALL_SECTIONS];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Parse a `--section-labels` spec overriding section titles (and optionally
|
|
162
|
+
* an emoji). Accepts `key=Label,other=Label2` (comma/semicolon separated).
|
|
163
|
+
* The label value may itself lead with an emoji + space, e.g.
|
|
164
|
+
* `blocked=π₯ On Fire` sets emoji "π₯" and title "On Fire"; a label with no
|
|
165
|
+
* leading emoji keeps the section's default emoji and only changes the title.
|
|
166
|
+
* Keys use the same aliases as `--sections` (wipβin_progress, etc.).
|
|
167
|
+
* Unknown keys are a USAGE error rather than a silent drop.
|
|
168
|
+
*/
|
|
169
|
+
export function parseSectionLabels(spec) {
|
|
170
|
+
const out = {};
|
|
171
|
+
if (!spec || !spec.trim())
|
|
172
|
+
return out;
|
|
173
|
+
for (const pair of spec.split(/[,;]/)) {
|
|
174
|
+
const eq = pair.indexOf("=");
|
|
175
|
+
if (eq < 0)
|
|
176
|
+
continue;
|
|
177
|
+
const rawKey = pair.slice(0, eq).trim().toLowerCase();
|
|
178
|
+
const rawVal = pair.slice(eq + 1).trim();
|
|
179
|
+
if (!rawKey || !rawVal)
|
|
180
|
+
continue;
|
|
181
|
+
const key = SECTION_ALIASES[rawKey];
|
|
182
|
+
if (!key) {
|
|
183
|
+
throw new CommandError(`Unknown --section-labels key '${rawKey}'. Valid: in_progress | blocked | done | up_next.`, EXIT_CODE.USAGE);
|
|
184
|
+
}
|
|
185
|
+
// Split on the first space; treat the head as an emoji override only
|
|
186
|
+
// when it carries a non-ASCII codepoint (emoji/symbol). A plain ASCII
|
|
187
|
+
// word is part of the title, so the section keeps its default emoji.
|
|
188
|
+
const spaceIdx = rawVal.indexOf(" ");
|
|
189
|
+
const override = {};
|
|
190
|
+
if (spaceIdx > 0) {
|
|
191
|
+
const head = rawVal.slice(0, spaceIdx);
|
|
192
|
+
const tail = rawVal.slice(spaceIdx + 1).trim();
|
|
193
|
+
const headHasNonAscii = [...head].some((ch) => ch.codePointAt(0) > 127);
|
|
194
|
+
if (tail && headHasNonAscii) {
|
|
195
|
+
override.emoji = head;
|
|
196
|
+
override.title = tail;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
override.title = rawVal;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
override.title = rawVal;
|
|
204
|
+
}
|
|
205
|
+
out[key] = override;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Parse a `--channels` spec (comma/semicolon list) into an ordered, de-duped
|
|
211
|
+
* list of channel targets. Each target is either a Slack channel name
|
|
212
|
+
* (e.g. `#team-eng`) or a full webhook URL β multi-channel posting accepts
|
|
213
|
+
* both (a name is shown in the message; a URL is POSTed to). Empty β [].
|
|
214
|
+
*/
|
|
215
|
+
export function parseChannels(spec) {
|
|
216
|
+
if (!spec || !spec.trim())
|
|
217
|
+
return [];
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const raw of spec.split(/[,;]/)) {
|
|
220
|
+
const token = raw.trim();
|
|
221
|
+
if (token && !out.includes(token))
|
|
222
|
+
out.push(token);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
/** True when a channel token is a full webhook URL rather than a name. */
|
|
227
|
+
export function isWebhookUrl(token) {
|
|
228
|
+
return /^https?:\/\//i.test(token.trim());
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the "recently closed" window start (ms epoch) from `--since` and/or
|
|
232
|
+
* `--days`. `--since` is an explicit ISO date/time; `--days <n>` is N days
|
|
233
|
+
* before now. If both are given the *later* (more restrictive) bound wins.
|
|
234
|
+
* Returns NaN when neither is set (no windowing). Invalid input β USAGE error.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveSinceMs(since, days, now = Date.now()) {
|
|
237
|
+
let bound = NaN;
|
|
238
|
+
if (since != null) {
|
|
239
|
+
const ms = Date.parse(since);
|
|
240
|
+
if (isNaN(ms)) {
|
|
241
|
+
throw new CommandError(`Invalid --since value '${since}' (expected an ISO date/time).`, EXIT_CODE.USAGE);
|
|
242
|
+
}
|
|
243
|
+
bound = ms;
|
|
244
|
+
}
|
|
245
|
+
if (days != null) {
|
|
246
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
247
|
+
throw new CommandError(`Invalid --days value '${days}' (expected a non-negative number).`, EXIT_CODE.USAGE);
|
|
248
|
+
}
|
|
249
|
+
const daysBound = now - days * 86_400_000;
|
|
250
|
+
bound = isNaN(bound) ? daysBound : Math.max(bound, daysBound);
|
|
251
|
+
}
|
|
252
|
+
return bound;
|
|
253
|
+
}
|
|
254
|
+
export function parseDays(raw) {
|
|
255
|
+
if (raw == null || raw.trim() === "")
|
|
256
|
+
return undefined;
|
|
257
|
+
const n = Number(raw.trim());
|
|
258
|
+
if (!Number.isFinite(n)) {
|
|
259
|
+
throw new CommandError(`Invalid --days value '${raw}' (expected a number).`, EXIT_CODE.USAGE);
|
|
260
|
+
}
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
87
263
|
// ---------------------------------------------------------------------------
|
|
88
264
|
// Data fetch
|
|
89
265
|
// ---------------------------------------------------------------------------
|
|
@@ -92,7 +268,7 @@ function parseMentionMap(spec) {
|
|
|
92
268
|
* status locally. This is a single pm invocation (vs. four list-by-status
|
|
93
269
|
* calls) and gives us bodies + assignee + timestamps for grouping/windowing.
|
|
94
270
|
*/
|
|
95
|
-
function fetchAllItems(pmRoot) {
|
|
271
|
+
export function fetchAllItems(pmRoot) {
|
|
96
272
|
const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], { encoding: "utf-8" });
|
|
97
273
|
if (result.error || result.status !== 0) {
|
|
98
274
|
console.error(`pm list-all failed: ${result.stderr ?? result.error?.message ?? ""}`);
|
|
@@ -113,29 +289,152 @@ const DONE_STATUSES = new Set(["closed", "done", "complete", "completed"]);
|
|
|
113
289
|
function statusOf(item) {
|
|
114
290
|
return (item.status ?? "").trim().toLowerCase();
|
|
115
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* True when an item's last activity falls within the [sinceMs, now] window.
|
|
294
|
+
* NaN sinceMs means "no window" β always true.
|
|
295
|
+
*/
|
|
296
|
+
export function withinWindow(item, sinceMs) {
|
|
297
|
+
if (isNaN(sinceMs))
|
|
298
|
+
return true;
|
|
299
|
+
const ts = Date.parse(item.updated_at ?? item.created_at ?? "");
|
|
300
|
+
return isNaN(ts) ? false : ts >= sinceMs;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* True when an item carries a `blocked_by` dependency, regardless of its
|
|
304
|
+
* status. pm surfaces this either as a top-level `blocked_by` string (item ID
|
|
305
|
+
* or free-text reason) or as one/more `dependencies` entries with
|
|
306
|
+
* `kind: "blocked_by"`. Used to surface impediments that are NOT explicitly
|
|
307
|
+
* status=blocked under the Blocked section.
|
|
308
|
+
*/
|
|
309
|
+
export function hasBlockedByDep(item) {
|
|
310
|
+
const top = item.blocked_by;
|
|
311
|
+
if (typeof top === "string" && top.trim().length > 0)
|
|
312
|
+
return true;
|
|
313
|
+
const deps = item.dependencies;
|
|
314
|
+
if (Array.isArray(deps)) {
|
|
315
|
+
for (const d of deps) {
|
|
316
|
+
if (d && typeof d.kind === "string" && d.kind.trim().toLowerCase() === "blocked_by") {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Local-day key (YYYY-MM-DD in the host's local timezone) for an item's last
|
|
325
|
+
* activity. Used by the `--yesterday` split. Falls back to created_at, then
|
|
326
|
+
* to the empty string when no timestamp is parseable.
|
|
327
|
+
*/
|
|
328
|
+
export function localDayKey(item) {
|
|
329
|
+
const ts = Date.parse(item.updated_at ?? item.created_at ?? "");
|
|
330
|
+
if (isNaN(ts))
|
|
331
|
+
return "";
|
|
332
|
+
const d = new Date(ts);
|
|
333
|
+
const y = d.getFullYear();
|
|
334
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
335
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
336
|
+
return `${y}-${m}-${day}`;
|
|
337
|
+
}
|
|
338
|
+
/** Local-day key (YYYY-MM-DD) for a given epoch-ms instant. */
|
|
339
|
+
export function localDayKeyOf(ms) {
|
|
340
|
+
const d = new Date(ms);
|
|
341
|
+
const y = d.getFullYear();
|
|
342
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
343
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
344
|
+
return `${y}-${m}-${day}`;
|
|
345
|
+
}
|
|
116
346
|
/**
|
|
117
347
|
* Bucket items into standup sections.
|
|
118
|
-
* `
|
|
119
|
-
* window; WIP/blocked/up-next always reflect current state.
|
|
348
|
+
* `sinceMs` (epoch ms, NaN = no window) filters the Done section to items
|
|
349
|
+
* updated within the window; WIP/blocked/up-next always reflect current state.
|
|
120
350
|
*/
|
|
121
|
-
function buildStandupData(items, opts) {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const blocked = items.filter((i) => BLOCKED_STATUSES.has(statusOf(i)));
|
|
131
|
-
const open = items.filter((i) => OPEN_STATUSES.has(statusOf(i)));
|
|
351
|
+
export function buildStandupData(items, opts, sinceMs = NaN, now = Date.now()) {
|
|
352
|
+
const isDone = (i) => DONE_STATUSES.has(statusOf(i));
|
|
353
|
+
// An item is "blocked" for standup purposes when its status is blocked/
|
|
354
|
+
// on_hold OR it carries a blocked_by dependency β but a done item is never
|
|
355
|
+
// re-surfaced as blocked (a closed impediment is no longer an impediment).
|
|
356
|
+
const isBlocked = (i) => !isDone(i) && (BLOCKED_STATUSES.has(statusOf(i)) || hasBlockedByDep(i));
|
|
357
|
+
const wip = items.filter((i) => WIP_STATUSES.has(statusOf(i)) && !isBlocked(i));
|
|
358
|
+
const blocked = items.filter(isBlocked);
|
|
359
|
+
const open = items.filter((i) => OPEN_STATUSES.has(statusOf(i)) && !isBlocked(i));
|
|
132
360
|
const done = opts.includeDone
|
|
133
|
-
? items.filter((i) =>
|
|
361
|
+
? items.filter((i) => isDone(i) && withinWindow(i, sinceMs))
|
|
134
362
|
: [];
|
|
135
363
|
const upNext = [...open]
|
|
136
364
|
.sort((a, b) => (a.priority ?? 9999) - (b.priority ?? 9999))
|
|
137
365
|
.slice(0, 3);
|
|
138
|
-
|
|
366
|
+
const data = { wip, blocked, done, upNext, total: items.length };
|
|
367
|
+
if (opts.splitYesterday && done.length > 0) {
|
|
368
|
+
const todayKey = localDayKeyOf(now);
|
|
369
|
+
const yesterdayKey = localDayKeyOf(now - 86_400_000);
|
|
370
|
+
data.doneToday = done.filter((i) => localDayKey(i) === todayKey);
|
|
371
|
+
data.doneYesterday = done.filter((i) => localDayKey(i) === yesterdayKey);
|
|
372
|
+
}
|
|
373
|
+
return data;
|
|
374
|
+
}
|
|
375
|
+
const SECTION_META = {
|
|
376
|
+
in_progress: { emoji: "π", title: "In Progress", emptyNote: "nothing in progress", withPriority: false },
|
|
377
|
+
blocked: { emoji: "π«", title: "Blocked", emptyNote: "nothing blocked", withPriority: false },
|
|
378
|
+
done: { emoji: "β
", title: "Done", emptyNote: null, withPriority: false },
|
|
379
|
+
up_next: { emoji: "π", title: "Up Next", emptyNote: null, withPriority: true },
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* Apply any `--section-labels` override for `key` to the given emoji/title.
|
|
383
|
+
*/
|
|
384
|
+
function labeled(key, emoji, title, opts) {
|
|
385
|
+
const ov = opts.sectionLabels[key];
|
|
386
|
+
if (!ov)
|
|
387
|
+
return { emoji, title };
|
|
388
|
+
return { emoji: ov.emoji ?? emoji, title: ov.title ?? title };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Resolve the ordered, selected section definitions for the given data.
|
|
392
|
+
* `in_progress` and `blocked` always render (even empty, with their note);
|
|
393
|
+
* `done` and `up_next` only render when they hold items β preserving the
|
|
394
|
+
* historical message shape. `--sections` filters which keys are eligible.
|
|
395
|
+
*
|
|
396
|
+
* When `--yesterday` is active and Done has items, the single Done section is
|
|
397
|
+
* expanded into "Done Yesterday" + "Done Today" (only the non-empty subsets
|
|
398
|
+
* render), preserving the section's position in the ordering.
|
|
399
|
+
*/
|
|
400
|
+
export function resolveSections(data, opts) {
|
|
401
|
+
const itemsFor = {
|
|
402
|
+
in_progress: data.wip,
|
|
403
|
+
blocked: data.blocked,
|
|
404
|
+
done: data.done,
|
|
405
|
+
up_next: data.upNext,
|
|
406
|
+
};
|
|
407
|
+
const alwaysShow = {
|
|
408
|
+
in_progress: true,
|
|
409
|
+
blocked: true,
|
|
410
|
+
done: false,
|
|
411
|
+
up_next: false,
|
|
412
|
+
};
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const key of opts.sections) {
|
|
415
|
+
const items = itemsFor[key];
|
|
416
|
+
const meta = SECTION_META[key];
|
|
417
|
+
if (key === "done" && opts.splitYesterday && data.doneYesterday && data.doneToday) {
|
|
418
|
+
const subsets = [
|
|
419
|
+
["Done Yesterday", data.doneYesterday],
|
|
420
|
+
["Done Today", data.doneToday],
|
|
421
|
+
];
|
|
422
|
+
// The day distinction owns the title here, so a --section-labels
|
|
423
|
+
// override for `done` contributes only its emoji (not its title).
|
|
424
|
+
const doneEmoji = opts.sectionLabels.done?.emoji ?? meta.emoji;
|
|
425
|
+
for (const [subTitle, subItems] of subsets) {
|
|
426
|
+
if (subItems.length === 0)
|
|
427
|
+
continue;
|
|
428
|
+
out.push({ key, emoji: doneEmoji, title: subTitle, items: subItems, emptyNote: meta.emptyNote, withPriority: meta.withPriority });
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!alwaysShow[key] && items.length === 0)
|
|
433
|
+
continue;
|
|
434
|
+
const { emoji, title } = labeled(key, meta.emoji, meta.title, opts);
|
|
435
|
+
out.push({ key, emoji, title, items, emptyNote: meta.emptyNote, withPriority: meta.withPriority });
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
139
438
|
}
|
|
140
439
|
// ---------------------------------------------------------------------------
|
|
141
440
|
// Rendering helpers
|
|
@@ -147,12 +446,12 @@ function typeLabel(item) {
|
|
|
147
446
|
return `[${label}]`;
|
|
148
447
|
}
|
|
149
448
|
function mentionFor(item, mentionMap) {
|
|
150
|
-
const author = item.assignee;
|
|
449
|
+
const author = item.assignee ?? item.author;
|
|
151
450
|
if (author && mentionMap[author])
|
|
152
451
|
return ` (${mentionMap[author]})`;
|
|
153
452
|
return "";
|
|
154
453
|
}
|
|
155
|
-
function itemText(item, mentionMap, withPriority = false) {
|
|
454
|
+
export function itemText(item, mentionMap, withPriority = false) {
|
|
156
455
|
const label = typeLabel(item);
|
|
157
456
|
const title = label ? `${label} ${item.title}` : item.title;
|
|
158
457
|
const prio = withPriority && item.priority != null ? ` (priority ${item.priority})` : "";
|
|
@@ -162,68 +461,122 @@ function todayISO() {
|
|
|
162
461
|
return new Date().toISOString().slice(0, 10);
|
|
163
462
|
}
|
|
164
463
|
/**
|
|
165
|
-
* Group a list of items by
|
|
166
|
-
*
|
|
464
|
+
* Group a list of items by the configured field (assignee, sprint or type).
|
|
465
|
+
* Items missing the field bucket under a synthetic "_none" key (rendered as a
|
|
466
|
+
* friendly label). Returns entries sorted by group key for stable output.
|
|
167
467
|
*/
|
|
168
|
-
function
|
|
468
|
+
export function groupItems(items, groupBy) {
|
|
169
469
|
const groups = new Map();
|
|
170
470
|
for (const item of items) {
|
|
171
|
-
|
|
172
|
-
(
|
|
471
|
+
let key;
|
|
472
|
+
if (groupBy === "assignee")
|
|
473
|
+
key = item.assignee ?? "_none";
|
|
474
|
+
else if (groupBy === "sprint")
|
|
475
|
+
key = item.sprint ?? "_none";
|
|
476
|
+
else if (groupBy === "type")
|
|
477
|
+
key = item.type ?? "_none";
|
|
478
|
+
else
|
|
479
|
+
key = "_none";
|
|
480
|
+
const bucket = groups.get(key);
|
|
481
|
+
if (bucket)
|
|
482
|
+
bucket.push(item);
|
|
483
|
+
else
|
|
484
|
+
groups.set(key, [item]);
|
|
173
485
|
}
|
|
174
486
|
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
175
487
|
}
|
|
488
|
+
function groupLabel(key, groupBy) {
|
|
489
|
+
if (key !== "_none")
|
|
490
|
+
return key;
|
|
491
|
+
if (groupBy === "assignee")
|
|
492
|
+
return "Unassigned";
|
|
493
|
+
if (groupBy === "sprint")
|
|
494
|
+
return "No sprint";
|
|
495
|
+
if (groupBy === "type")
|
|
496
|
+
return "Untyped";
|
|
497
|
+
return key;
|
|
498
|
+
}
|
|
499
|
+
const isGrouped = (opts) => opts.groupBy !== "status";
|
|
176
500
|
// ---------------------------------------------------------------------------
|
|
177
|
-
// Plain-text / mrkdwn message (fallback + dry-run preview)
|
|
501
|
+
// Plain-text / mrkdwn / markdown message (fallback + dry-run preview)
|
|
178
502
|
// ---------------------------------------------------------------------------
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
503
|
+
function bold(text, format) {
|
|
504
|
+
if (format === "slack")
|
|
505
|
+
return `*${text}*`;
|
|
506
|
+
if (format === "markdown")
|
|
507
|
+
return `**${text}**`;
|
|
508
|
+
return text;
|
|
509
|
+
}
|
|
510
|
+
function italic(text, format) {
|
|
511
|
+
if (format === "slack")
|
|
512
|
+
return `_${text}_`;
|
|
513
|
+
if (format === "markdown")
|
|
514
|
+
return `_${text}_`;
|
|
515
|
+
return text;
|
|
516
|
+
}
|
|
517
|
+
function renderSection(lines, def, opts) {
|
|
518
|
+
const count = `(${def.items.length})`;
|
|
519
|
+
if (opts.format === "markdown") {
|
|
520
|
+
lines.push(`## ${def.emoji} ${def.title} ${count}`);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
lines.push(`${def.emoji} ${bold(def.title, opts.format)} ${count}`);
|
|
524
|
+
}
|
|
525
|
+
if (def.items.length === 0) {
|
|
526
|
+
if (def.emptyNote) {
|
|
527
|
+
const bullet = opts.format === "markdown" ? "- " : "β’ ";
|
|
528
|
+
lines.push(`${bullet}${italic(def.emptyNote, opts.format)}`);
|
|
529
|
+
}
|
|
185
530
|
return;
|
|
186
531
|
}
|
|
187
|
-
if (opts
|
|
188
|
-
for (const [
|
|
189
|
-
const name =
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
532
|
+
if (isGrouped(opts)) {
|
|
533
|
+
for (const [key, group] of groupItems(def.items, opts.groupBy)) {
|
|
534
|
+
const name = groupLabel(key, opts.groupBy);
|
|
535
|
+
if (opts.format === "markdown")
|
|
536
|
+
lines.push(`- ${bold(name, opts.format)}`);
|
|
537
|
+
else
|
|
538
|
+
lines.push(` ${bold(name, opts.format)}`);
|
|
539
|
+
for (const item of group) {
|
|
540
|
+
const bullet = opts.format === "markdown" ? " - " : " β’ ";
|
|
541
|
+
lines.push(`${bullet}${itemText(item, opts.mentionMap, def.withPriority)}`);
|
|
542
|
+
}
|
|
193
543
|
}
|
|
194
544
|
}
|
|
195
545
|
else {
|
|
196
|
-
|
|
197
|
-
|
|
546
|
+
const bullet = opts.format === "markdown" ? "- " : "β’ ";
|
|
547
|
+
for (const item of def.items)
|
|
548
|
+
lines.push(`${bullet}${itemText(item, opts.mentionMap, def.withPriority)}`);
|
|
198
549
|
}
|
|
199
550
|
}
|
|
200
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Render the standup as a single text blob for the chosen non-Block-Kit
|
|
553
|
+
* format. `slack` is byte-identical to the historical output (mrkdwn);
|
|
554
|
+
* `plain` drops emphasis punctuation; `markdown` uses `#`/`**`/`-`.
|
|
555
|
+
*/
|
|
556
|
+
export function buildTextMessage(data, opts) {
|
|
201
557
|
const lines = [];
|
|
202
558
|
const dateStr = todayISO();
|
|
203
|
-
if (opts.channel)
|
|
204
|
-
lines.push(`> Channel: ${opts.channel}`);
|
|
205
|
-
lines.push(opts.format === "slack" ? `π *pm standup* β ${dateStr}` : `π pm standup β ${dateStr}`);
|
|
206
|
-
lines.push("");
|
|
207
|
-
renderSection(lines, "π", "In Progress", data.wip, "nothing in progress", opts);
|
|
208
|
-
lines.push("");
|
|
209
|
-
renderSection(lines, "π«", "Blocked", data.blocked, "nothing blocked", opts);
|
|
210
|
-
if (data.done.length > 0) {
|
|
211
|
-
lines.push("");
|
|
212
|
-
renderSection(lines, "β
", "Done", data.done, null, opts);
|
|
213
|
-
}
|
|
214
|
-
if (data.upNext.length > 0) {
|
|
215
|
-
lines.push("");
|
|
216
|
-
renderSection(lines, "π", "Up Next", data.upNext, null, opts, true);
|
|
559
|
+
if (opts.channel) {
|
|
560
|
+
lines.push(opts.format === "markdown" ? `> Channel: ${opts.channel}` : `> Channel: ${opts.channel}`);
|
|
217
561
|
}
|
|
562
|
+
const title = `π ${bold("pm standup", opts.format)} β ${dateStr}`;
|
|
563
|
+
lines.push(opts.format === "markdown" ? `# π pm standup β ${dateStr}` : title);
|
|
564
|
+
lines.push("");
|
|
565
|
+
const sections = resolveSections(data, opts);
|
|
566
|
+
sections.forEach((def, idx) => {
|
|
567
|
+
if (idx > 0)
|
|
568
|
+
lines.push("");
|
|
569
|
+
renderSection(lines, def, opts);
|
|
570
|
+
});
|
|
218
571
|
return lines.join("\n");
|
|
219
572
|
}
|
|
220
573
|
function mrkdwnList(items, opts, withPriority = false) {
|
|
221
574
|
if (items.length === 0)
|
|
222
575
|
return "_none_";
|
|
223
|
-
if (opts
|
|
576
|
+
if (isGrouped(opts)) {
|
|
224
577
|
const parts = [];
|
|
225
|
-
for (const [
|
|
226
|
-
const name =
|
|
578
|
+
for (const [key, group] of groupItems(items, opts.groupBy)) {
|
|
579
|
+
const name = groupLabel(key, opts.groupBy);
|
|
227
580
|
parts.push(`*${name}*`);
|
|
228
581
|
for (const item of group)
|
|
229
582
|
parts.push(`β’ ${itemText(item, opts.mentionMap, withPriority)}`);
|
|
@@ -233,17 +586,23 @@ function mrkdwnList(items, opts, withPriority = false) {
|
|
|
233
586
|
return items.map((item) => `β’ ${itemText(item, opts.mentionMap, withPriority)}`).join("\n");
|
|
234
587
|
}
|
|
235
588
|
/**
|
|
236
|
-
* Build a Slack Block Kit `blocks` array: a header, a section per
|
|
237
|
-
* bucket
|
|
238
|
-
*
|
|
239
|
-
*
|
|
589
|
+
* Build a Slack Block Kit `blocks` array: a header, a section per selected
|
|
590
|
+
* standup bucket and a context footer. Returns the blocks plus a plain-text
|
|
591
|
+
* `fallback` Slack renders in notifications and old clients.
|
|
592
|
+
*
|
|
593
|
+
* Block Kit schema choices: a single `header` block (plain_text, capped at
|
|
594
|
+
* Slack's 150-char limit), one `section`/`mrkdwn` block per bucket (Slack
|
|
595
|
+
* caps section text at 3000 chars β long buckets are truncated with an
|
|
596
|
+
* ellipsis to stay valid), a `divider`, then a `context` footer summarizing
|
|
597
|
+
* counts / window / grouping.
|
|
240
598
|
*/
|
|
241
|
-
function buildBlockKit(data, opts) {
|
|
599
|
+
export function buildBlockKit(data, opts) {
|
|
242
600
|
const blocks = [];
|
|
243
601
|
const dateStr = todayISO();
|
|
602
|
+
const truncate = (text, max) => text.length <= max ? text : text.slice(0, max - 1) + "β¦";
|
|
244
603
|
blocks.push({
|
|
245
604
|
type: "header",
|
|
246
|
-
text: { type: "plain_text", text: `π pm standup β ${dateStr}`, emoji: true },
|
|
605
|
+
text: { type: "plain_text", text: truncate(`π pm standup β ${dateStr}`, 150), emoji: true },
|
|
247
606
|
});
|
|
248
607
|
if (opts.channel) {
|
|
249
608
|
blocks.push({
|
|
@@ -251,39 +610,52 @@ function buildBlockKit(data, opts) {
|
|
|
251
610
|
elements: [{ type: "mrkdwn", text: `Channel: ${opts.channel}` }],
|
|
252
611
|
});
|
|
253
612
|
}
|
|
254
|
-
const
|
|
613
|
+
for (const def of resolveSections(data, opts)) {
|
|
255
614
|
blocks.push({
|
|
256
615
|
type: "section",
|
|
257
616
|
text: {
|
|
258
617
|
type: "mrkdwn",
|
|
259
|
-
text: `${emoji} *${title}* (${items.length})\n${mrkdwnList(items, opts, withPriority)}`,
|
|
618
|
+
text: truncate(`${def.emoji} *${def.title}* (${def.items.length})\n${mrkdwnList(def.items, opts, def.withPriority)}`, 3000),
|
|
260
619
|
},
|
|
261
620
|
});
|
|
262
|
-
}
|
|
263
|
-
section("π", "In Progress", data.wip);
|
|
264
|
-
section("π«", "Blocked", data.blocked);
|
|
265
|
-
section("π", "Up Next", data.upNext, true);
|
|
266
|
-
if (data.done.length > 0)
|
|
267
|
-
section("β
", "Done", data.done);
|
|
621
|
+
}
|
|
268
622
|
blocks.push({ type: "divider" });
|
|
623
|
+
const groupNote = {
|
|
624
|
+
status: null,
|
|
625
|
+
assignee: "grouped by assignee",
|
|
626
|
+
sprint: "grouped by sprint",
|
|
627
|
+
type: "grouped by type",
|
|
628
|
+
};
|
|
269
629
|
const footerBits = [
|
|
270
630
|
`${data.total} item(s) total`,
|
|
271
631
|
opts.since ? `since ${opts.since}` : null,
|
|
272
|
-
opts.groupBy
|
|
632
|
+
groupNote[opts.groupBy],
|
|
273
633
|
].filter(Boolean);
|
|
274
634
|
blocks.push({
|
|
275
635
|
type: "context",
|
|
276
636
|
elements: [{ type: "mrkdwn", text: `π€ pm-slack-standup Β· ${footerBits.join(" Β· ")}` }],
|
|
277
637
|
});
|
|
278
|
-
// Plain-text fallback mirrors the text message
|
|
638
|
+
// Plain-text fallback mirrors the slack-mrkdwn text message.
|
|
279
639
|
const fallback = buildTextMessage(data, { ...opts, format: "slack" });
|
|
280
640
|
return { blocks, fallback };
|
|
281
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Render the standup in whichever `--format` was selected, as the string the
|
|
644
|
+
* command prints (dry-run) or the exporter writes. `blockkit` returns the
|
|
645
|
+
* pretty-printed `{ blocks }` JSON; everything else returns text.
|
|
646
|
+
*/
|
|
647
|
+
export function renderStandup(data, opts) {
|
|
648
|
+
if (opts.format === "blockkit") {
|
|
649
|
+
const { blocks } = buildBlockKit(data, opts);
|
|
650
|
+
return JSON.stringify({ blocks }, null, 2);
|
|
651
|
+
}
|
|
652
|
+
return buildTextMessage(data, opts);
|
|
653
|
+
}
|
|
282
654
|
// ---------------------------------------------------------------------------
|
|
283
655
|
// Slack transport
|
|
284
656
|
// ---------------------------------------------------------------------------
|
|
285
657
|
function postToSlack(webhookUrl, payload) {
|
|
286
|
-
return new Promise((
|
|
658
|
+
return new Promise((resolvePromise, reject) => {
|
|
287
659
|
const body = JSON.stringify(payload);
|
|
288
660
|
const url = new URL(webhookUrl);
|
|
289
661
|
const options = {
|
|
@@ -300,7 +672,7 @@ function postToSlack(webhookUrl, payload) {
|
|
|
300
672
|
res.on("data", (chunk) => (respBody += chunk.toString()));
|
|
301
673
|
res.on("end", () => {
|
|
302
674
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
303
|
-
|
|
675
|
+
resolvePromise();
|
|
304
676
|
}
|
|
305
677
|
else {
|
|
306
678
|
reject(new Error(`Slack webhook returned HTTP ${res.statusCode ?? "unknown"}: ${respBody}`));
|
|
@@ -313,27 +685,92 @@ function postToSlack(webhookUrl, payload) {
|
|
|
313
685
|
req.end();
|
|
314
686
|
});
|
|
315
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* Resolve the ordered list of post targets from `--webhook`/env + `--channel`
|
|
690
|
+
* + `--channels`. Each `--channels` token is either a `#name` (posted to the
|
|
691
|
+
* base webhook, just changing the displayed channel) or a full webhook URL
|
|
692
|
+
* (posted to that URL). When no `--channels` is given, a single target using
|
|
693
|
+
* the base webhook + `--channel` is returned. De-dupes (webhook,channel) pairs.
|
|
694
|
+
*/
|
|
695
|
+
export function resolvePostTargets(baseWebhook, baseChannel, channels) {
|
|
696
|
+
if (channels.length === 0) {
|
|
697
|
+
return [{ webhookUrl: baseWebhook, channel: baseChannel }];
|
|
698
|
+
}
|
|
699
|
+
const out = [];
|
|
700
|
+
const seen = new Set();
|
|
701
|
+
for (const token of channels) {
|
|
702
|
+
const target = isWebhookUrl(token)
|
|
703
|
+
? { webhookUrl: token, channel: baseChannel }
|
|
704
|
+
: { webhookUrl: baseWebhook, channel: token };
|
|
705
|
+
const dedupeKey = `${target.webhookUrl}${target.channel ?? ""}`;
|
|
706
|
+
if (seen.has(dedupeKey))
|
|
707
|
+
continue;
|
|
708
|
+
seen.add(dedupeKey);
|
|
709
|
+
out.push(target);
|
|
710
|
+
}
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Post the standup to every resolved target, re-rendering per target so each
|
|
715
|
+
* channel's message shows its own channel name. Returns a per-target result;
|
|
716
|
+
* never throws β the caller decides how to treat failures (e.g. fallback to
|
|
717
|
+
* stdout). The `poster` is injectable so this is testable without a network.
|
|
718
|
+
*/
|
|
719
|
+
export async function postStandupTargets(targets, data, baseOpts, poster) {
|
|
720
|
+
const results = [];
|
|
721
|
+
for (const target of targets) {
|
|
722
|
+
const opts = { ...baseOpts, channel: target.channel };
|
|
723
|
+
const { blocks, fallback } = buildBlockKit(data, opts);
|
|
724
|
+
try {
|
|
725
|
+
await poster(target.webhookUrl, { text: fallback, blocks, mrkdwn: true });
|
|
726
|
+
results.push({ channel: target.channel, ok: true });
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
results.push({
|
|
730
|
+
channel: target.channel,
|
|
731
|
+
ok: false,
|
|
732
|
+
error: err instanceof Error ? err.message : String(err),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return results;
|
|
737
|
+
}
|
|
316
738
|
// ---------------------------------------------------------------------------
|
|
317
739
|
// Shared option resolution
|
|
318
740
|
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
741
|
+
/**
|
|
742
|
+
* Resolve every standup option except the render `format`, which differs
|
|
743
|
+
* between the command (slack|blockkit|markdown|plain) and the exporter
|
|
744
|
+
* (md|json file format). Callers supply the format they want.
|
|
745
|
+
*/
|
|
746
|
+
export function resolveStandupOptions(options, format) {
|
|
747
|
+
const since = readStrOption(options, "since");
|
|
748
|
+
const days = parseDays(readStrOption(options, "days"));
|
|
749
|
+
const splitYesterday = readBoolOption(options, "yesterday");
|
|
750
|
+
const opts = {
|
|
323
751
|
channel: readStrOption(options, "channel"),
|
|
324
|
-
format
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
752
|
+
format,
|
|
753
|
+
// `--yesterday` is meaningless without a Done section, so it implies
|
|
754
|
+
// `--include-done` (additive: passing only `--include-done` is unchanged).
|
|
755
|
+
includeDone: readBoolOption(options, "include-done") || splitYesterday,
|
|
756
|
+
since,
|
|
757
|
+
groupBy: parseGroupBy(readStrOption(options, "group-by")),
|
|
758
|
+
sections: parseSections(readStrOption(options, "sections")),
|
|
328
759
|
mentionMap: parseMentionMap(readStrOption(options, "mention-map")),
|
|
760
|
+
splitYesterday,
|
|
761
|
+
sectionLabels: parseSectionLabels(readStrOption(options, "section-labels")),
|
|
329
762
|
};
|
|
763
|
+
// `--days` implies windowing the Done section; surface it even without
|
|
764
|
+
// `--include-done` being set so the footer/window stays accurate.
|
|
765
|
+
const sinceMs = resolveSinceMs(since, days);
|
|
766
|
+
return { opts, sinceMs };
|
|
330
767
|
}
|
|
331
768
|
// ---------------------------------------------------------------------------
|
|
332
769
|
// Extension
|
|
333
770
|
// ---------------------------------------------------------------------------
|
|
334
771
|
export default defineExtension({
|
|
335
772
|
name: "pm-slack-standup",
|
|
336
|
-
version: "2026.6.
|
|
773
|
+
version: "2026.6.4",
|
|
337
774
|
activate(api) {
|
|
338
775
|
api.registerCommand({
|
|
339
776
|
name: "standup",
|
|
@@ -342,43 +779,48 @@ export default defineExtension({
|
|
|
342
779
|
examples: [
|
|
343
780
|
"pm standup --webhook https://hooks.slack.com/services/...",
|
|
344
781
|
"pm standup --channel '#team-eng' --dry-run",
|
|
345
|
-
"pm standup --
|
|
782
|
+
"pm standup --dry-run --format blockkit",
|
|
783
|
+
"pm standup --dry-run --format markdown --include-done --days 7",
|
|
346
784
|
"pm standup --group-by assignee --mention-map 'alice=@alice.s,bob=@bob'",
|
|
785
|
+
"pm standup --group-by sprint --sections in_progress,blocked",
|
|
786
|
+
"pm standup --dry-run --yesterday --format plain",
|
|
787
|
+
"pm standup --channels '#team-eng,#standups' --dry-run",
|
|
788
|
+
"pm standup --section-labels 'in_progress=Rolling,blocked=π₯ On Fire' --dry-run",
|
|
347
789
|
"PM_SLACK_WEBHOOK=https://... pm standup --channel '#standups'",
|
|
348
790
|
],
|
|
349
791
|
flags: [
|
|
350
792
|
{ long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK env var)" },
|
|
351
793
|
{ long: "--channel", value_name: "name", description: "Channel name shown in the message (e.g. #team-eng)" },
|
|
352
|
-
{ long: "--dry-run", description: "
|
|
794
|
+
{ long: "--dry-run", description: "Build and print the message in the chosen format WITHOUT posting to Slack" },
|
|
795
|
+
{ long: "--format", value_name: "fmt", description: "Output format: slack (mrkdwn, default) | blockkit (JSON) | markdown | plain" },
|
|
353
796
|
{ long: "--include-done", description: "Include recently-closed items in a Done section" },
|
|
354
|
-
{ long: "--since", value_name: "iso", description: "ISO date/time window;
|
|
355
|
-
{ long: "--
|
|
797
|
+
{ long: "--since", value_name: "iso", description: "ISO date/time window; scopes the Done section to items updated since then" },
|
|
798
|
+
{ long: "--days", value_name: "n", description: "Relative window: scope Done to items updated in the last N days" },
|
|
799
|
+
{ long: "--group-by", value_name: "field", description: "Group section items by status (default) | assignee | sprint | type" },
|
|
800
|
+
{ long: "--sections", value_name: "list", description: "Comma list of sections to render: in_progress,blocked,done,up_next" },
|
|
356
801
|
{ long: "--mention-map", value_name: "map", description: "Map pm authors to Slack handles, e.g. 'alice=@alice,bob=@bob'" },
|
|
357
|
-
{ long: "--
|
|
802
|
+
{ long: "--yesterday", description: "Split the Done section into 'Done Yesterday' / 'Done Today' by local day (implies --include-done)" },
|
|
803
|
+
{ long: "--channels", value_name: "list", description: "Post the same standup to multiple targets: comma list of #channel names and/or webhook URLs" },
|
|
804
|
+
{ long: "--fallback-to-stdout", description: "If the Slack post fails, print the rendered standup to stdout instead of exiting non-zero" },
|
|
805
|
+
{ long: "--section-labels", value_name: "map", description: "Override section titles/emoji, e.g. 'in_progress=Rolling,blocked=π₯ On Fire'" },
|
|
358
806
|
],
|
|
359
807
|
async run(ctx) {
|
|
360
808
|
const webhookUrl = readStrOption(ctx.options, "webhook") ?? process.env["PM_SLACK_WEBHOOK"] ?? "";
|
|
361
809
|
const dryRun = readBoolOption(ctx.options, "dry-run");
|
|
362
|
-
const opts = resolveStandupOptions(ctx.options);
|
|
363
|
-
if (!dryRun && !webhookUrl) {
|
|
364
|
-
// Graceful no-op when no webhook is configured: warn and exit 0 so the
|
|
365
|
-
// command never blocks a workflow on a missing/unset webhook.
|
|
366
|
-
console.error("PM_SLACK_WEBHOOK not set and no --webhook provided β Slack posting disabled. " +
|
|
367
|
-
"Use --dry-run to preview the message.");
|
|
368
|
-
return { posted: false, disabled: true, reason: "no-webhook" };
|
|
369
|
-
}
|
|
810
|
+
const { opts, sinceMs } = resolveStandupOptions(ctx.options, parseFormat(readStrOption(ctx.options, "format")));
|
|
370
811
|
const items = fetchAllItems(ctx.pm_root);
|
|
371
|
-
const data = buildStandupData(items, opts);
|
|
372
|
-
const { blocks, fallback } = buildBlockKit(data, opts);
|
|
373
|
-
const textPreview = opts.format === "text" ? buildTextMessage(data, opts) : fallback;
|
|
812
|
+
const data = buildStandupData(items, opts, sinceMs);
|
|
374
813
|
if (dryRun) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
console.error(
|
|
378
|
-
process.stdout.write(
|
|
814
|
+
// No network call happens on this path.
|
|
815
|
+
const rendered = renderStandup(data, opts);
|
|
816
|
+
console.error(`--- DRY RUN (${opts.format}, message not posted) ---`);
|
|
817
|
+
process.stdout.write(rendered + "\n");
|
|
379
818
|
console.error("--- END ---");
|
|
819
|
+
const { blocks, fallback } = buildBlockKit(data, opts);
|
|
380
820
|
return {
|
|
381
821
|
dryRun: true,
|
|
822
|
+
format: opts.format,
|
|
823
|
+
rendered,
|
|
382
824
|
blocks,
|
|
383
825
|
fallback,
|
|
384
826
|
wip: data.wip.length,
|
|
@@ -387,17 +829,46 @@ export default defineExtension({
|
|
|
387
829
|
upNext: data.upNext.length,
|
|
388
830
|
};
|
|
389
831
|
}
|
|
390
|
-
|
|
391
|
-
|
|
832
|
+
const channels = parseChannels(readStrOption(ctx.options, "channels"));
|
|
833
|
+
const fallbackToStdout = readBoolOption(ctx.options, "fallback-to-stdout");
|
|
834
|
+
// Real post path: a missing webhook is a hard, structured error (exit 1)
|
|
835
|
+
// rather than a crash or silent success. `--channels` entries that are
|
|
836
|
+
// bare #names still need a base webhook to post to. Use --dry-run to
|
|
837
|
+
// preview, or --fallback-to-stdout to print instead of erroring.
|
|
838
|
+
const needsBaseWebhook = channels.length === 0 || channels.some((c) => !isWebhookUrl(c));
|
|
839
|
+
if (!webhookUrl && needsBaseWebhook) {
|
|
840
|
+
throw new CommandError("No Slack webhook configured. Set PM_SLACK_WEBHOOK or pass --webhook <url>, " +
|
|
841
|
+
"or use --dry-run to preview the message without posting.", EXIT_CODE.GENERIC_FAILURE);
|
|
392
842
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
843
|
+
const targets = resolvePostTargets(webhookUrl, opts.channel, channels);
|
|
844
|
+
const results = await postStandupTargets(targets, data, opts, postToSlack);
|
|
845
|
+
const failures = results.filter((r) => !r.ok);
|
|
846
|
+
if (failures.length > 0 && fallbackToStdout) {
|
|
847
|
+
// Print the rendered standup so the work isn't lost on a transport
|
|
848
|
+
// failure. We exit 0 here: stdout delivery is the requested fallback.
|
|
849
|
+
for (const f of failures) {
|
|
850
|
+
console.error(`Slack post to ${f.channel ?? "(default channel)"} failed: ${f.error ?? "unknown error"} β falling back to stdout.`);
|
|
851
|
+
}
|
|
852
|
+
const rendered = renderStandup(data, opts);
|
|
853
|
+
process.stdout.write(rendered + "\n");
|
|
854
|
+
return {
|
|
855
|
+
posted: results.some((r) => r.ok),
|
|
856
|
+
fallbackToStdout: true,
|
|
857
|
+
results,
|
|
858
|
+
wip: data.wip.length,
|
|
859
|
+
blocked: data.blocked.length,
|
|
860
|
+
done: data.done.length,
|
|
861
|
+
upNext: data.upNext.length,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
if (failures.length > 0) {
|
|
865
|
+
throw new CommandError(`Slack post failed for ${failures.length} of ${targets.length} target(s): ` +
|
|
866
|
+
failures.map((f) => `${f.channel ?? "(default)"}: ${f.error ?? "unknown"}`).join("; "), EXIT_CODE.GENERIC_FAILURE);
|
|
398
867
|
}
|
|
399
868
|
return {
|
|
400
869
|
posted: true,
|
|
870
|
+
channel: opts.channel,
|
|
871
|
+
channels: targets.length > 1 ? targets.map((t) => t.channel) : undefined,
|
|
401
872
|
wip: data.wip.length,
|
|
402
873
|
blocked: data.blocked.length,
|
|
403
874
|
done: data.done.length,
|
|
@@ -412,14 +883,16 @@ export default defineExtension({
|
|
|
412
883
|
// (No collision with the `pm standup` command β different invocation.)
|
|
413
884
|
// -----------------------------------------------------------------------
|
|
414
885
|
api.registerExporter("standup", async (ctx) => {
|
|
415
|
-
const opts = resolveStandupOptions(ctx.options);
|
|
416
886
|
const rawFormat = (readStrOption(ctx.options, "format") ?? "md").toLowerCase();
|
|
417
|
-
// For the exporter, --format selects the file format (md|json); the
|
|
418
|
-
//
|
|
887
|
+
// For the exporter, --format selects the file format (md|json); the text
|
|
888
|
+
// rendering is always markdown. We resolve options with markdown rather
|
|
889
|
+
// than routing the exporter's md|json through the command's --format
|
|
890
|
+
// validator (which only knows slack|blockkit|markdown|plain).
|
|
419
891
|
const fileFormat = rawFormat === "json" ? "json" : "md";
|
|
420
|
-
const
|
|
892
|
+
const { opts, sinceMs } = resolveStandupOptions(ctx.options, "markdown");
|
|
893
|
+
const exportOpts = opts;
|
|
421
894
|
const items = fetchAllItems(ctx.pm_root);
|
|
422
|
-
const data = buildStandupData(items, exportOpts);
|
|
895
|
+
const data = buildStandupData(items, exportOpts, sinceMs);
|
|
423
896
|
let output;
|
|
424
897
|
if (fileFormat === "json") {
|
|
425
898
|
const { blocks, fallback } = buildBlockKit(data, opts);
|
|
@@ -428,6 +901,7 @@ export default defineExtension({
|
|
|
428
901
|
channel: opts.channel,
|
|
429
902
|
since: opts.since,
|
|
430
903
|
groupBy: opts.groupBy,
|
|
904
|
+
sections: opts.sections,
|
|
431
905
|
counts: {
|
|
432
906
|
wip: data.wip.length,
|
|
433
907
|
blocked: data.blocked.length,
|
|
@@ -435,7 +909,7 @@ export default defineExtension({
|
|
|
435
909
|
upNext: data.upNext.length,
|
|
436
910
|
total: data.total,
|
|
437
911
|
},
|
|
438
|
-
|
|
912
|
+
sections_data: {
|
|
439
913
|
in_progress: data.wip,
|
|
440
914
|
blocked: data.blocked,
|
|
441
915
|
up_next: data.upNext,
|
|
@@ -445,11 +919,7 @@ export default defineExtension({
|
|
|
445
919
|
}, null, 2);
|
|
446
920
|
}
|
|
447
921
|
else {
|
|
448
|
-
|
|
449
|
-
const md = buildTextMessage(data, exportOpts)
|
|
450
|
-
.replace(/^π pm standup β (.+)$/m, "# pm standup β $1")
|
|
451
|
-
.replace(/^(π|π«|β
|π) (.+)$/gm, "## $1 $2");
|
|
452
|
-
output = md;
|
|
922
|
+
output = buildTextMessage(data, exportOpts);
|
|
453
923
|
}
|
|
454
924
|
const outputPath = readStrOption(ctx.options, "output");
|
|
455
925
|
if (outputPath) {
|