typescript104 lines · 3.2 KB
1/** Room messages. Four kinds, one shape. No labeled dumps. */
2import type { Mapping, Work } from "./mapping.ts";
3import { plan } from "./mapping.ts";
4
5export type Kind = "kickoff" | "nudge" | "blocked" | "done";
6export type RoomState = "active" | "blocked" | "closed";
7export type RoomPlatform = "slack" | "discord";
8
9const EXPECT: Record<Kind, RoomState> = {
10 kickoff: "active",
11 nudge: "active",
12 blocked: "blocked",
13 done: "closed",
14};
15
16const CTA: Record<Kind, string> = {
17 kickoff: "Reply in this thread. One status is enough.",
18 nudge: "Still open. Reply in this thread.",
19 blocked: "Blocked. Reply here only if that changed.",
20 done: "Closed.",
21};
22
23export class BroadcastError extends Error {}
24
25export type Broadcast = {
26 kind: Kind;
27 workId: string;
28 title: string;
29 state: RoomState;
30 owner?: string;
31 assignee?: string;
32 quote?: string;
33 quoteFrom?: string;
34 platform?: RoomPlatform;
35};
36
37export function render(b: Broadcast): string {
38 if (!(b.kind in EXPECT)) throw new BroadcastError(`unknown kind: ${b.kind}`);
39 if (!(b.state === "active" || b.state === "blocked" || b.state === "closed")) {
40 throw new BroadcastError(`unknown state: ${b.state}`);
41 }
42 if (b.state !== EXPECT[b.kind]) {
43 throw new BroadcastError(`${b.kind} requires state ${EXPECT[b.kind]}`);
44 }
45 const title = line(b.title);
46 const workId = line(b.workId);
47 if (!title || !workId) throw new BroadcastError("title and work id are required");
48 const lines = [bold(title, b.platform ?? "slack"), `\`${workId}\` · ${b.state}`];
49 const who = people(line(b.owner ?? ""), line(b.assignee ?? ""));
50 if (who.length) lines.push("", ...who);
51 const quote = line(b.quote ?? "");
52 if (quote) {
53 lines.push("", "> " + quote);
54 const from = line(b.quoteFrom ?? "");
55 if (from) lines.push("> — " + from);
56 }
57 lines.push("", CTA[b.kind]);
58 const text = lines.join("\n") + "\n";
59 for (const banned of ["broadcast:", "state:", "owner:", "in flight:", "context:"]) {
60 if (text.includes(banned)) throw new BroadcastError(`labeled dump leaked: ${banned}`);
61 }
62 return text;
63}
64
65export function kickoffFor(
66 work: Work,
67 extra: { assignee?: string; quote?: string; quoteFrom?: string; platform?: RoomPlatform } = {},
68): string {
69 return render({
70 kind: "kickoff",
71 workId: work.id,
72 title: work.name || work.id,
73 state: work.state as RoomState,
74 owner: work.owner,
75 assignee: extra.assignee,
76 quote: extra.quote,
77 quoteFrom: extra.quoteFrom,
78 platform: extra.platform,
79 });
80}
81
82export function previewPosts(mapping: Mapping, platform: RoomPlatform = "slack"): string {
83 const chunks = plan(mapping)
84 .filter((row) => row.action === "post")
85 .map((row) => kickoffFor(row.work, { platform }));
86 if (!chunks.length) return "";
87 return "# would post\n\n" + chunks.join("\n");
88}
89
90function people(owner: string, assignee: string): string[] {
91 const lines: string[] = [];
92 if (owner) lines.push(`${owner} owns it`);
93 if (assignee) lines.push(`${assignee} is on it`);
94 return lines;
95}
96
97function bold(text: string, platform: RoomPlatform): string {
98 return platform === "discord" ? `**${text}**` : `*${text}*`;
99}
100
101function line(text: string): string {
102 return text.split(/\s+/).filter(Boolean).join(" ");
103}
104