1
/** Room messages. Four kinds, one shape. No labeled dumps. */2
import type { Mapping, Work } from "./mapping.ts";3
import { plan } from "./mapping.ts";4
5
export type Kind = "kickoff" | "nudge" | "blocked" | "done";6
export type RoomState = "active" | "blocked" | "closed";7
export type RoomPlatform = "slack" | "discord";8
9
const EXPECT: Record<Kind, RoomState> = {10
kickoff: "active",11
nudge: "active",12
blocked: "blocked",13
done: "closed",14
};15
16
const 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
23
export class BroadcastError extends Error {}24
25
export 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
37
export 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
65
export 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
82
export 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
90
function 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
97
function bold(text: string, platform: RoomPlatform): string {98
return platform === "discord" ? `**${text}**` : `*${text}*`;99
}100
101
function line(text: string): string {102
return text.split(/\s+/).filter(Boolean).join(" ");103
}104