@bytesbrains/pi-telegram-bridge 1.0.1 → 1.1.0

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.
@@ -0,0 +1,789 @@
1
+ /**
2
+ * pi-telegram-bridge — Rich Message Templates
3
+ *
4
+ * Well-designed notification templates for Telegram.
5
+ * All use Telegram MarkdownV2 format (no tables, no triple backticks).
6
+ * Supports: issue, PR, CI/CD, work progress, agent session, factory, alerts.
7
+ */
8
+
9
+ // ── Helpers ─────────────────────────────────────────────────────────
10
+
11
+ const BAR = "━".repeat(30);
12
+
13
+ function bold(s: string) {
14
+ return `*${s}*`;
15
+ }
16
+ function italic(s: string) {
17
+ return `_${s}_`;
18
+ }
19
+ function code(s: string) {
20
+ return `\`${s}\``;
21
+ }
22
+ function strike(s: string) {
23
+ return `~${s}~`;
24
+ }
25
+ function labelValue(label: string, value: string) {
26
+ return `${bold(label + ":")} ${value}`;
27
+ }
28
+ function progressBar(pct: number, width = 12) {
29
+ const filled = Math.round((pct / 100) * width);
30
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)} ${pct}%`;
31
+ }
32
+ // ── CI/CD Status Icons ─────────────────────────────────────────────
33
+
34
+ function jobIcon(status: string): string {
35
+ const map: Record<string, string> = {
36
+ success: "✅",
37
+ failure: "❌",
38
+ cancelled: "⚪",
39
+ skipped: "⏭️",
40
+ running: "🔵",
41
+ queued: "⏳",
42
+ pending: "🟡",
43
+ in_progress: "🔵",
44
+ };
45
+ return map[status] ?? "❓";
46
+ }
47
+
48
+ // ── Issue Tray (compact, swipe-friendly) ───────────────────────────
49
+
50
+ export interface IssueCard {
51
+ number: number;
52
+ title: string;
53
+ state: "open" | "closed";
54
+ status?: string; // "in progress", "blocked", etc.
55
+ assignee?: string;
56
+ labels?: string[];
57
+ milestone?: string;
58
+ repo: { owner: string; name: string };
59
+ branch?: string;
60
+ pr?: { number: number; status: string };
61
+ url?: string;
62
+ }
63
+
64
+ export function issueCard(i: IssueCard): string {
65
+ const icon =
66
+ i.state === "closed" ? "✅" : i.status === "blocked" ? "🚫" : "📋";
67
+ const closed = i.state === "closed";
68
+
69
+ let msg = `${icon} ${bold(`Issue #${i.number}`)}${closed ? strike("") : ""}: ${closed ? strike(i.title) : bold(i.title)}\n`;
70
+ msg += `${BAR}\n`;
71
+
72
+ if (i.status) {
73
+ const statusIcon =
74
+ i.status === "in progress" ? "🔄" : i.status === "blocked" ? "🚫" : "📌";
75
+ msg +=
76
+ labelValue(
77
+ "Status",
78
+ `${closed ? strike(`${statusIcon} ${i.status}`) : `${statusIcon} ${i.status}`}`,
79
+ ) + "\n";
80
+ }
81
+ if (i.assignee) msg += labelValue("Assignee", code(i.assignee)) + "\n";
82
+ if (i.labels && i.labels.length > 0) {
83
+ msg += labelValue("Labels", i.labels.map((l) => code(l)).join(" ")) + "\n";
84
+ }
85
+ if (i.milestone) msg += labelValue("Milestone", italic(i.milestone)) + "\n";
86
+ msg += labelValue("Repo", italic(`${i.repo.owner}/${i.repo.name}`)) + "\n";
87
+ msg += `${BAR}\n`;
88
+ if (i.branch) msg += labelValue("Branch", code(i.branch)) + "\n";
89
+ if (i.pr) {
90
+ msg +=
91
+ labelValue("PR", `[#${i.pr.number}](${i.url ?? ""}) — ${i.pr.status}`) +
92
+ "\n";
93
+ }
94
+ if (i.url) msg += `\n[View on GitHub](${i.url})`;
95
+ return msg;
96
+ }
97
+
98
+ // ── PR Status Card ──────────────────────────────────────────────────
99
+
100
+ export interface PRCard {
101
+ number: number;
102
+ title: string;
103
+ state: "open" | "closed" | "merged" | "draft";
104
+ author: string;
105
+ base: string;
106
+ head: string;
107
+ repo: { owner: string; name: string };
108
+ ci?: { passed: number; failed: number; total: number; status?: string };
109
+ reviews?: { approved: number; requested: number; changesRequested: number };
110
+ files?: { added: number; removed: number; changed: number };
111
+ breaking?: boolean;
112
+ draft?: boolean;
113
+ url?: string;
114
+ }
115
+
116
+ export function prCard(pr: PRCard): string {
117
+ const stateIcon =
118
+ pr.state === "merged"
119
+ ? "💜"
120
+ : pr.state === "closed"
121
+ ? "⚫"
122
+ : pr.draft
123
+ ? "📝"
124
+ : "🔀";
125
+ const stateBadge =
126
+ pr.state === "merged"
127
+ ? "💜 Merged"
128
+ : pr.state === "closed"
129
+ ? "⚫ Closed"
130
+ : pr.draft
131
+ ? "📝 Draft"
132
+ : "🟡 Open";
133
+
134
+ let msg = `${stateIcon} ${bold(`PR #${pr.number}`)}: ${italic(pr.title)}\n`;
135
+ msg += `${BAR}\n`;
136
+ msg += labelValue("Status", stateBadge) + "\n";
137
+ msg += labelValue("Author", code(pr.author)) + "\n";
138
+ msg += labelValue("Branch", `${code(pr.base)} ← ${code(pr.head)}`) + "\n";
139
+
140
+ // CI status
141
+ if (pr.ci) {
142
+ const icon = pr.ci.status
143
+ ? jobIcon(pr.ci.status)
144
+ : pr.ci.failed === 0
145
+ ? "✅"
146
+ : "❌";
147
+ msg +=
148
+ labelValue(
149
+ "CI",
150
+ `${icon} ${pr.ci.passed}/${pr.ci.total} checks passed` +
151
+ (pr.ci.failed > 0 ? ` · ${pr.ci.failed} failed` : ""),
152
+ ) + "\n";
153
+ }
154
+
155
+ // Reviews
156
+ if (pr.reviews) {
157
+ const parts: string[] = [];
158
+ if (pr.reviews.approved > 0)
159
+ parts.push(`✅ ${pr.reviews.approved} approved`);
160
+ if (pr.reviews.changesRequested > 0)
161
+ parts.push(`🔴 ${pr.reviews.changesRequested} changes req`);
162
+ if (pr.reviews.requested > 0)
163
+ parts.push(`⏳ ${pr.reviews.requested} pending`);
164
+ msg += labelValue("Reviews", parts.join(" · ")) + "\n";
165
+ }
166
+
167
+ if (pr.files) {
168
+ msg +=
169
+ labelValue(
170
+ "Files",
171
+ `${pr.files.changed} files (+${pr.files.added} −${pr.files.removed})`,
172
+ ) + "\n";
173
+ }
174
+ if (pr.breaking) msg += labelValue("Breaking", "⚠️ Yes") + "\n";
175
+
176
+ msg += `${BAR}\n`;
177
+ if (pr.url) msg += `[View on GitHub](${pr.url})`;
178
+ return msg;
179
+ }
180
+
181
+ // ── Work / Task Progress ────────────────────────────────────────────
182
+
183
+ export interface TaskProgress {
184
+ title: string;
185
+ emoji?: string;
186
+ status: "started" | "in-progress" | "paused" | "blocked" | "complete";
187
+ progress?: number; // 0–100
188
+ elapsed?: string; // "3m 22s"
189
+ eta?: string;
190
+ details?: string[];
191
+ metrics?: Record<string, string>;
192
+ }
193
+
194
+ export function taskProgress(t: TaskProgress): string {
195
+ const icons: Record<string, string> = {
196
+ started: "🟢",
197
+ "in-progress": "🔵",
198
+ paused: "🟡",
199
+ blocked: "🚫",
200
+ complete: "✅",
201
+ };
202
+ const emoji = t.emoji ?? "🛠️";
203
+ const icon = icons[t.status] ?? "🔵";
204
+
205
+ let msg = `${emoji} ${bold(t.title)}\n`;
206
+ msg += `${BAR}\n`;
207
+ msg += labelValue("Status", `${icon} ${t.status}`) + "\n";
208
+
209
+ if (t.progress !== undefined) {
210
+ msg += labelValue("Progress", progressBar(t.progress)) + "\n";
211
+ }
212
+ if (t.elapsed) msg += labelValue("Elapsed", code(t.elapsed)) + "\n";
213
+ if (t.eta) msg += labelValue("ETA", code(t.eta)) + "\n";
214
+
215
+ if (t.details && t.details.length > 0) {
216
+ msg += `${BAR}\n`;
217
+ for (const d of t.details) {
218
+ msg += ` ${d}\n`;
219
+ }
220
+ }
221
+
222
+ if (t.metrics && Object.keys(t.metrics).length > 0) {
223
+ msg += `${BAR}\n`;
224
+ for (const [k, v] of Object.entries(t.metrics)) {
225
+ msg += labelValue(k, code(v)) + "\n";
226
+ }
227
+ }
228
+ return msg;
229
+ }
230
+
231
+ // ── CI Pipeline ─────────────────────────────────────────────────────
232
+
233
+ export interface CIPipeline {
234
+ workflow: string;
235
+ runNumber: number;
236
+ branch: string;
237
+ event: string; // "push", "pull_request"
238
+ jobs: Array<{ name: string; status: string; duration?: string }>;
239
+ triggeredBy?: string;
240
+ url?: string;
241
+ }
242
+
243
+ export function ciPipeline(p: CIPipeline): string {
244
+ let msg = `🔄 ${bold(p.workflow)}\n`;
245
+ msg += `${BAR}\n`;
246
+ msg += labelValue("Run", `#${p.runNumber}`) + "\n";
247
+ msg += labelValue("Branch", code(p.branch)) + "\n";
248
+ msg += labelValue("Trigger", p.event) + "\n";
249
+ if (p.triggeredBy) msg += labelValue("By", code(p.triggeredBy)) + "\n";
250
+ msg += `${BAR}\n`;
251
+
252
+ for (const j of p.jobs) {
253
+ const dur = j.duration ? ` (${j.duration})` : "";
254
+ msg += `${jobIcon(j.status)} ${code(j.name)}${dur}\n`;
255
+ }
256
+
257
+ if (p.url) msg += `\n[View Pipeline](${p.url})`;
258
+ return msg;
259
+ }
260
+
261
+ // ── Agent Session Card ──────────────────────────────────────────────
262
+
263
+ export interface SessionCard {
264
+ session: string;
265
+ branch?: string;
266
+ model?: string;
267
+ turns?: number;
268
+ tokens?: { used: number; total: number };
269
+ duration?: string;
270
+ status: "idle" | "streaming" | "tool-exec" | "waiting";
271
+ }
272
+
273
+ export function sessionCard(s: SessionCard): string {
274
+ const statusIcons: Record<string, string> = {
275
+ idle: "⏸️",
276
+ streaming: "💬",
277
+ "tool-exec": "🔧",
278
+ waiting: "⏳",
279
+ };
280
+ const icon = statusIcons[s.status] ?? "🤖";
281
+
282
+ let msg = `${icon} ${bold("Agent Session")}\n`;
283
+ msg += `${BAR}\n`;
284
+ msg += labelValue("Session", code(s.session)) + "\n";
285
+ if (s.branch) msg += labelValue("Branch", code(s.branch)) + "\n";
286
+ if (s.model) msg += labelValue("Model", italic(s.model)) + "\n";
287
+ if (s.turns !== undefined) msg += labelValue("Turns", `${s.turns}`) + "\n";
288
+ if (s.tokens) {
289
+ const pct = Math.round((s.tokens.used / s.tokens.total) * 100);
290
+ msg +=
291
+ labelValue(
292
+ "Tokens",
293
+ `${s.tokens.used.toLocaleString()} / ${s.tokens.total.toLocaleString()} (${pct}%)`,
294
+ ) + "\n";
295
+ }
296
+ if (s.duration) msg += labelValue("Duration", code(s.duration)) + "\n";
297
+ return msg;
298
+ }
299
+
300
+ // ── Alert / Error ───────────────────────────────────────────────────
301
+
302
+ export interface AlertCard {
303
+ level: "info" | "warning" | "error" | "critical";
304
+ title: string;
305
+ source?: string;
306
+ detail?: string;
307
+ action?: string;
308
+ stack?: string;
309
+ }
310
+
311
+ export function alertCard(a: AlertCard): string {
312
+ const icons: Record<string, string> = {
313
+ info: "ℹ️",
314
+ warning: "⚠️",
315
+ error: "🚨",
316
+ critical: "🛑",
317
+ };
318
+ const icon = icons[a.level] ?? "📢";
319
+
320
+ let msg = `${icon} ${bold(a.title)}\n`;
321
+ msg += `${BAR}\n`;
322
+ if (a.source) msg += labelValue("Source", code(a.source)) + "\n";
323
+ if (a.detail) msg += labelValue("Detail", italic(a.detail)) + "\n";
324
+ if (a.action) msg += labelValue("Action", bold(a.action)) + "\n";
325
+ if (a.stack) {
326
+ msg += `\n${code(a.stack.slice(0, 300))}\n`;
327
+ }
328
+ return msg;
329
+ }
330
+
331
+ // ── Factory / Orchestrator ──────────────────────────────────────────
332
+
333
+ export interface FactoryJobCard {
334
+ jobId: string;
335
+ mode: "single" | "chain" | "parallel";
336
+ status: "pending" | "running" | "success" | "failure" | "cancelled";
337
+ agent?: string;
338
+ task?: string;
339
+ environment?: string;
340
+ duration?: string;
341
+ worker?: string;
342
+ }
343
+
344
+ export function factoryJobCard(j: FactoryJobCard): string {
345
+ const icons: Record<string, string> = {
346
+ pending: "⏳",
347
+ running: "🔵",
348
+ success: "✅",
349
+ failure: "❌",
350
+ cancelled: "⚪",
351
+ };
352
+ const icon = icons[j.status] ?? "❓";
353
+
354
+ let msg = `${icon} ${bold("Factory Job")} ${code(j.jobId)}\n`;
355
+ msg += `${BAR}\n`;
356
+ msg += labelValue("Status", `${icon} ${j.status}`) + "\n";
357
+ msg += labelValue("Mode", code(j.mode)) + "\n";
358
+ if (j.agent) msg += labelValue("Agent", code(j.agent)) + "\n";
359
+ if (j.task) msg += labelValue("Task", italic(j.task.slice(0, 120))) + "\n";
360
+ if (j.environment) msg += labelValue("Env", code(j.environment)) + "\n";
361
+ if (j.worker) msg += labelValue("Worker", code(j.worker)) + "\n";
362
+ if (j.duration) msg += labelValue("Duration", code(j.duration)) + "\n";
363
+ return msg;
364
+ }
365
+
366
+ // ── Multi-Item Summaries ────────────────────────────────────────────
367
+
368
+ export interface SummaryHeader {
369
+ title: string;
370
+ subtitle?: string;
371
+ items?: Array<{ icon: string; label: string; value: string }>;
372
+ }
373
+
374
+ export function summaryHeader(s: SummaryHeader): string {
375
+ let msg = `${bold(s.title)}\n`;
376
+ if (s.subtitle) msg += `${italic(s.subtitle)}\n`;
377
+ msg += `${BAR}\n`;
378
+ if (s.items) {
379
+ for (const item of s.items) {
380
+ msg += `${item.icon} ${labelValue(item.label, code(item.value))}\n`;
381
+ }
382
+ }
383
+ return msg;
384
+ }
385
+
386
+ // ── Quick Acknowledgment ────────────────────────────────────────────
387
+
388
+ export function ack(text: string, emoji = "✅"): string {
389
+ return `${emoji} ${italic(text)}`;
390
+ }
391
+
392
+ // ── Decision Card (for override/approval context) ───────────────────
393
+
394
+ export interface DecisionCard {
395
+ question: string;
396
+ context: string;
397
+ options: string[];
398
+ risk: "low" | "medium" | "high" | "critical";
399
+ command?: string;
400
+ reason?: string;
401
+ }
402
+
403
+ export function decisionCard(d: DecisionCard): string {
404
+ const riskIcons: Record<string, string> = {
405
+ low: "🟢",
406
+ medium: "🟡",
407
+ high: "🟠",
408
+ critical: "🔴",
409
+ };
410
+
411
+ let msg = `🛑 ${bold("Decision Required")}\n`;
412
+ msg += `${BAR}\n`;
413
+ msg += labelValue("Risk", `${riskIcons[d.risk]} ${d.risk}`) + "\n";
414
+ msg += labelValue("Context", italic(d.context)) + "\n";
415
+ if (d.command) msg += labelValue("Command", code(d.command)) + "\n";
416
+ if (d.reason) msg += labelValue("Reason", d.reason) + "\n";
417
+ msg += `\n${bold("❓ " + d.question)}\n`;
418
+ msg += `\n${d.options.map((o, i) => `${i + 1}\\. ${o}`).join("\n")}`;
419
+ return msg;
420
+ }
421
+
422
+ // ── Composite: Daily Standup / Status Roundup ───────────────────────
423
+
424
+ export interface StandupCard {
425
+ date: string;
426
+ agent: string;
427
+ workedOn: string[];
428
+ nextUp: string[];
429
+ blockers: string[];
430
+ metrics?: Record<string, string>;
431
+ }
432
+
433
+ export function standupCard(s: StandupCard): string {
434
+ let msg = `📊 ${bold("Status Roundup")}\n`;
435
+ msg += `${BAR}\n`;
436
+ msg += labelValue("Date", code(s.date)) + "\n";
437
+ msg += labelValue("Agent", code(s.agent)) + "\n";
438
+ msg += `${BAR}\n`;
439
+
440
+ msg += `${bold("✅ Completed")}\n`;
441
+ for (const item of s.workedOn) {
442
+ msg += ` • ${item}\n`;
443
+ }
444
+ msg += `\n${bold("🔄 Next")}\n`;
445
+ for (const item of s.nextUp) {
446
+ msg += ` • ${item}\n`;
447
+ }
448
+ if (s.blockers.length > 0) {
449
+ msg += `\n${bold("🚫 Blockers")}\n`;
450
+ for (const item of s.blockers) {
451
+ msg += ` • ${item}\n`;
452
+ }
453
+ }
454
+ if (s.metrics && Object.keys(s.metrics).length > 0) {
455
+ msg += `${BAR}\n`;
456
+ for (const [k, v] of Object.entries(s.metrics)) {
457
+ msg += labelValue(k, code(v)) + "\n";
458
+ }
459
+ }
460
+ return msg;
461
+ }
462
+
463
+ // ── Diff / Change Summary ───────────────────────────────────────────
464
+
465
+ export interface DiffSummary {
466
+ title: string;
467
+ stats: { added: number; removed: number; files: number };
468
+ highlights: string[];
469
+ url?: string;
470
+ }
471
+
472
+ export function diffSummary(d: DiffSummary): string {
473
+ let msg = `📝 ${bold(d.title)}\n`;
474
+ msg += `${BAR}\n`;
475
+ msg +=
476
+ labelValue(
477
+ "Changes",
478
+ `+${d.stats.added} −${d.stats.removed} in ${d.stats.files} files`,
479
+ ) + "\n";
480
+ if (d.highlights.length > 0) {
481
+ msg += `${BAR}\n`;
482
+ for (const h of d.highlights) {
483
+ msg += ` • ${h}\n`;
484
+ }
485
+ }
486
+ if (d.url) msg += `\n[View Diff](${d.url})`;
487
+ return msg;
488
+ }
489
+
490
+ // ── Quick Pickers (for telegram_ask buttons) ────────────────────────
491
+
492
+ export function quickActions(label: string, actions: string[]): string {
493
+ return `${bold(label)}\n${actions.map((a, i) => `${i + 1}\\. ${code(a)}`).join(" \n")}`;
494
+ }
495
+
496
+ export function yesNoExplain(prompt: string): string {
497
+ return `❓ ${bold(prompt)}\n\nPick an option below:`;
498
+ }
499
+
500
+ // ── Build Notification (main dispatch for telegram_notify tool) ────
501
+
502
+ // Flattened input from the tool; only the relevant subset is used per kind.
503
+ export interface NotifyInput {
504
+ kind:
505
+ | "issue"
506
+ | "pr"
507
+ | "task"
508
+ | "pipeline"
509
+ | "session"
510
+ | "alert"
511
+ | "factory-job"
512
+ | "standup"
513
+ | "diff"
514
+ | "decision"
515
+ | "ack";
516
+ // issue
517
+ issueNumber?: number;
518
+ issueTitle?: string;
519
+ issueState?: "open" | "closed";
520
+ issueStatus?: string;
521
+ issueAssignee?: string;
522
+ issueLabels?: string[];
523
+ issueMilestone?: string;
524
+ issueRepoOwner?: string;
525
+ issueRepoName?: string;
526
+ issueBranch?: string;
527
+ issuePRNumber?: number;
528
+ issuePRStatus?: string;
529
+ issueUrl?: string;
530
+ // pr
531
+ prNumber?: number;
532
+ prTitle?: string;
533
+ prState?: "open" | "closed" | "merged" | "draft";
534
+ prAuthor?: string;
535
+ prBase?: string;
536
+ prHead?: string;
537
+ prRepoOwner?: string;
538
+ prRepoName?: string;
539
+ prCIPassed?: number;
540
+ prCIFailed?: number;
541
+ prCITotal?: number;
542
+ prCIStatus?: string;
543
+ prReviewsApproved?: number;
544
+ prReviewsRequested?: number;
545
+ prReviewsChanges?: number;
546
+ prFilesAdded?: number;
547
+ prFilesRemoved?: number;
548
+ prFilesChanged?: number;
549
+ prBreaking?: boolean;
550
+ prDraft?: boolean;
551
+ prUrl?: string;
552
+ // task
553
+ taskTitle?: string;
554
+ taskEmoji?: string;
555
+ taskStatus?: "started" | "in-progress" | "paused" | "blocked" | "complete";
556
+ taskProgress?: number;
557
+ taskElapsed?: string;
558
+ taskEta?: string;
559
+ taskDetails?: string[];
560
+ taskMetrics?: Record<string, string>;
561
+ // pipeline
562
+ pipelineWorkflow?: string;
563
+ pipelineRunNumber?: number;
564
+ pipelineBranch?: string;
565
+ pipelineEvent?: string;
566
+ pipelineJobs?: Array<{ name: string; status: string; duration?: string }>;
567
+ pipelineTriggeredBy?: string;
568
+ pipelineUrl?: string;
569
+ // session
570
+ sessionName?: string;
571
+ sessionBranch?: string;
572
+ sessionModel?: string;
573
+ sessionTurns?: number;
574
+ sessionTokensUsed?: number;
575
+ sessionTokensTotal?: number;
576
+ sessionDuration?: string;
577
+ sessionStatus?: "idle" | "streaming" | "tool-exec" | "waiting";
578
+ // alert
579
+ alertLevel?: "info" | "warning" | "error" | "critical";
580
+ alertTitle?: string;
581
+ alertSource?: string;
582
+ alertDetail?: string;
583
+ alertAction?: string;
584
+ alertStack?: string;
585
+ // factory-job
586
+ jobId?: string;
587
+ jobMode?: "single" | "chain" | "parallel";
588
+ jobStatus?: "pending" | "running" | "success" | "failure" | "cancelled";
589
+ jobAgent?: string;
590
+ jobTask?: string;
591
+ jobEnvironment?: string;
592
+ jobDuration?: string;
593
+ jobWorker?: string;
594
+ // standup
595
+ standupDate?: string;
596
+ standupAgent?: string;
597
+ standupWorkedOn?: string[];
598
+ standupNextUp?: string[];
599
+ standupBlockers?: string[];
600
+ standupMetrics?: Record<string, string>;
601
+ // diff
602
+ diffTitle?: string;
603
+ diffAdded?: number;
604
+ diffRemoved?: number;
605
+ diffFiles?: number;
606
+ diffHighlights?: string[];
607
+ diffUrl?: string;
608
+ // decision
609
+ decisionQuestion?: string;
610
+ decisionContext?: string;
611
+ decisionOptions?: string[];
612
+ decisionRisk?: "low" | "medium" | "high" | "critical";
613
+ decisionCommand?: string;
614
+ decisionReason?: string;
615
+ // ack
616
+ ackText?: string;
617
+ ackEmoji?: string;
618
+ }
619
+
620
+ export function buildNotification(input: NotifyInput): string {
621
+ switch (input.kind) {
622
+ case "issue":
623
+ return issueCard({
624
+ number: input.issueNumber ?? 0,
625
+ title: input.issueTitle ?? "Unknown",
626
+ state: input.issueState ?? "open",
627
+ status: input.issueStatus,
628
+ assignee: input.issueAssignee,
629
+ labels: input.issueLabels,
630
+ milestone: input.issueMilestone,
631
+ repo: {
632
+ owner: input.issueRepoOwner ?? "unknown",
633
+ name: input.issueRepoName ?? "unknown",
634
+ },
635
+ branch: input.issueBranch,
636
+ pr: input.issuePRNumber
637
+ ? {
638
+ number: input.issuePRNumber,
639
+ status: input.issuePRStatus ?? "unknown",
640
+ }
641
+ : undefined,
642
+ url: input.issueUrl,
643
+ });
644
+
645
+ case "pr":
646
+ return prCard({
647
+ number: input.prNumber ?? 0,
648
+ title: input.prTitle ?? "Unknown",
649
+ state: input.prState ?? "open",
650
+ author: input.prAuthor ?? "unknown",
651
+ base: input.prBase ?? "main",
652
+ head: input.prHead ?? "unknown",
653
+ repo: {
654
+ owner: input.prRepoOwner ?? "unknown",
655
+ name: input.prRepoName ?? "unknown",
656
+ },
657
+ ci:
658
+ input.prCITotal !== undefined
659
+ ? {
660
+ passed: input.prCIPassed ?? 0,
661
+ failed: input.prCIFailed ?? 0,
662
+ total: input.prCITotal,
663
+ status: input.prCIStatus,
664
+ }
665
+ : undefined,
666
+ reviews:
667
+ input.prReviewsApproved !== undefined ||
668
+ input.prReviewsRequested !== undefined ||
669
+ input.prReviewsChanges !== undefined
670
+ ? {
671
+ approved: input.prReviewsApproved ?? 0,
672
+ requested: input.prReviewsRequested ?? 0,
673
+ changesRequested: input.prReviewsChanges ?? 0,
674
+ }
675
+ : undefined,
676
+ files:
677
+ input.prFilesChanged !== undefined
678
+ ? {
679
+ added: input.prFilesAdded ?? 0,
680
+ removed: input.prFilesRemoved ?? 0,
681
+ changed: input.prFilesChanged,
682
+ }
683
+ : undefined,
684
+ breaking: input.prBreaking,
685
+ draft: input.prDraft,
686
+ url: input.prUrl,
687
+ });
688
+
689
+ case "task":
690
+ return taskProgress({
691
+ title: input.taskTitle ?? "Task",
692
+ emoji: input.taskEmoji,
693
+ status: input.taskStatus ?? "started",
694
+ progress: input.taskProgress,
695
+ elapsed: input.taskElapsed,
696
+ eta: input.taskEta,
697
+ details: input.taskDetails,
698
+ metrics: input.taskMetrics,
699
+ });
700
+
701
+ case "pipeline":
702
+ return ciPipeline({
703
+ workflow: input.pipelineWorkflow ?? "CI",
704
+ runNumber: input.pipelineRunNumber ?? 0,
705
+ branch: input.pipelineBranch ?? "unknown",
706
+ event: input.pipelineEvent ?? "push",
707
+ jobs: input.pipelineJobs ?? [],
708
+ triggeredBy: input.pipelineTriggeredBy,
709
+ url: input.pipelineUrl,
710
+ });
711
+
712
+ case "session":
713
+ return sessionCard({
714
+ session: input.sessionName ?? "unknown",
715
+ branch: input.sessionBranch,
716
+ model: input.sessionModel,
717
+ turns: input.sessionTurns,
718
+ tokens:
719
+ input.sessionTokensTotal !== undefined
720
+ ? {
721
+ used: input.sessionTokensUsed ?? 0,
722
+ total: input.sessionTokensTotal,
723
+ }
724
+ : undefined,
725
+ duration: input.sessionDuration,
726
+ status: input.sessionStatus ?? "idle",
727
+ });
728
+
729
+ case "alert":
730
+ return alertCard({
731
+ level: input.alertLevel ?? "info",
732
+ title: input.alertTitle ?? "Alert",
733
+ source: input.alertSource,
734
+ detail: input.alertDetail,
735
+ action: input.alertAction,
736
+ stack: input.alertStack,
737
+ });
738
+
739
+ case "factory-job":
740
+ return factoryJobCard({
741
+ jobId: input.jobId ?? "unknown",
742
+ mode: input.jobMode ?? "single",
743
+ status: input.jobStatus ?? "pending",
744
+ agent: input.jobAgent,
745
+ task: input.jobTask,
746
+ environment: input.jobEnvironment,
747
+ duration: input.jobDuration,
748
+ worker: input.jobWorker,
749
+ });
750
+
751
+ case "standup":
752
+ return standupCard({
753
+ date: input.standupDate ?? "unknown",
754
+ agent: input.standupAgent ?? "pi",
755
+ workedOn: input.standupWorkedOn ?? [],
756
+ nextUp: input.standupNextUp ?? [],
757
+ blockers: input.standupBlockers ?? [],
758
+ metrics: input.standupMetrics,
759
+ });
760
+
761
+ case "diff":
762
+ return diffSummary({
763
+ title: input.diffTitle ?? "Changes",
764
+ stats: {
765
+ added: input.diffAdded ?? 0,
766
+ removed: input.diffRemoved ?? 0,
767
+ files: input.diffFiles ?? 0,
768
+ },
769
+ highlights: input.diffHighlights ?? [],
770
+ url: input.diffUrl,
771
+ });
772
+
773
+ case "decision":
774
+ return decisionCard({
775
+ question: input.decisionQuestion ?? "Proceed?",
776
+ context: input.decisionContext ?? "",
777
+ options: input.decisionOptions ?? ["Yes", "No"],
778
+ risk: input.decisionRisk ?? "medium",
779
+ command: input.decisionCommand,
780
+ reason: input.decisionReason,
781
+ });
782
+
783
+ case "ack":
784
+ return ack(input.ackText ?? "Done", input.ackEmoji);
785
+
786
+ default:
787
+ return "Unknown notification kind";
788
+ }
789
+ }