@mikitasazan/notify 1.7.0 → 1.8.1

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/dist/render.js CHANGED
@@ -1,29 +1,30 @@
1
1
  /**
2
- * Один рендерер на тип события, все по одному каркасуутверждён
3
- * владельцем 20.08.2026 после ~15 живых раундов в тестовом форуме:
2
+ * One renderer per event type, all built on one skeleton approved by
3
+ * the owner on 20.08.2026 after ~15 live rounds in the test forum:
4
4
  *
5
- * #тип #экземпляр
6
- * значок <b>Тип:</b> действие
5
+ * #type #instance
6
+ * icon <b>Type:</b> action
7
7
  *
8
- * <b>Ярлык:</b> значение
9
- * <blockquote>цитата чужого текстатело коммита, тело задачи</blockquote>
8
+ * <b>Label:</b> value
9
+ * <blockquote>quoted text from someone else commit body, task body</blockquote>
10
10
  *
11
- * <i><u>Группа</u></i>
12
- * <b>#N (overdue):</b> <a>заголовок</a>
11
+ * <i><u>Group</u></i>
12
+ * <b>#N (overdue):</b> <a>title</a>
13
13
  *
14
- * <b>Ярлык:</b> значениедействия/направления
14
+ * <b>Label:</b> valueactions/directions
15
15
  *
16
- * Три уровня начертания, никогда не смешиваются: поле жирный ярлык с
17
- * большой буквы + обычное значение; группа курсив+подчёркивание, без
18
- * жирности и без двоеточия; строка 2 (тип) тот же закон поля. Пустая
19
- * строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
20
- * после каждой строки.
16
+ * Three levels of styling, never mixed: a field is a bold, capitalized
17
+ * label plus a plain value; a group is italic+underline, no bold and no
18
+ * colon; line 2 (the type) follows the same field rule. A blank line
19
+ * separates BLOCKS BY MEANING (header / body / actions), not mechanically
20
+ * after every line.
21
21
  */
22
22
  import { ICON, iconFor } from "./events.js";
23
- /** Первая буква заглавная, остальное как есть (ga4/GitHub остаются собой). */
23
+ /** First letter capitalized, the rest left as is (ga4/GitHub stay themselves). */
24
24
  /**
25
- * Ярлык с большой буквы но НЕ у имени, которое пишется со строчной нарочно:
26
- * `iOS` превращалось в `IOS`. Признак вторая буква заглавная.
25
+ * A label gets a capital letter but NOT a name that is deliberately
26
+ * written lowercase: `iOS` was turning into `IOS`. The signal is a capital
27
+ * second letter.
27
28
  */
28
29
  const cap = (s) => {
29
30
  if (s.length === 0 || /^[a-z][A-Z]/.test(s)) {
@@ -31,24 +32,25 @@ const cap = (s) => {
31
32
  }
32
33
  return s.charAt(0).toUpperCase() + s.slice(1);
33
34
  };
34
- /** Экранируется ВСЁ, что пришло снаружитеги ставит только шаблон. */
35
+ /** Escapes EVERYTHING that comes from outside only the template adds tags. */
35
36
  export const esc = (v) => String(v ?? '')
36
37
  .replace(/&/g, '&amp;')
37
38
  .replace(/</g, '&lt;')
38
39
  .replace(/>/g, '&gt;')
39
40
  .replace(/"/g, '&quot;');
40
41
  /**
41
- * Telegram режет сообщение на 4096 символахрежем сами, по возможности по
42
- * границе строки.
42
+ * Telegram cuts a message at 4096 characterswe cut it ourselves first,
43
+ * on a line boundary where possible.
43
44
  *
44
- * Два подвоха, оба приводили к ТИХОЙ потере сообщения:
45
- * 1. Резать строго по последнему `\n` нельзя: если длинный кусок идёт одной
46
- * строкой (стектрейс, вывод командысамый частый `detail` у инцидента),
47
- * последний перевод строки стоит ПЕРЕД ним, и содержимое выбрасывалось
48
- * целикомприходил заголовок без единого факта о поломке.
49
- * 2. Резать посреди HTML-тега или сущности тоже нельзя: Telegram отвечает
50
- * `400 can't parse entities`, а 4xx мы считаем постоянной ошибкой и не
51
- * повторяем сообщение исчезало совсем.
45
+ * Two traps, both caused a SILENT loss of the message:
46
+ * 1. Cutting strictly at the last `\n` does not work: if a long chunk runs
47
+ * as one line (a stack trace, command output the most common `detail`
48
+ * on an incident), the last line break sits BEFORE it, and the whole
49
+ * content got dropped only the heading arrived, with not a single
50
+ * fact about what broke.
51
+ * 2. Cutting in the middle of an HTML tag or entity does not work either:
52
+ * Telegram replies `400 can't parse entities`, and we treat a 4xx as a
53
+ * permanent error and do not retry — the message disappeared for good.
52
54
  */
53
55
  export const clampMessage = (text, limit = 4000) => {
54
56
  if (text.length <= limit) {
@@ -56,9 +58,9 @@ export const clampMessage = (text, limit = 4000) => {
56
58
  }
57
59
  const cut = text.slice(0, limit);
58
60
  const lastBreak = cut.lastIndexOf('\n');
59
- // По границе строкитолько если так остаётся большая часть содержимого.
61
+ // Cut on a line boundary only if that keeps most of the content.
60
62
  let end = lastBreak > limit * 0.6 ? lastBreak : limit;
61
- // Не обрываемся внутри `<...>` и внутри `&...;` — иначе разметка ломается.
63
+ // Never cut inside `<...>` or inside `&...;` — otherwise the markup breaks.
62
64
  const openTag = cut.lastIndexOf('<', end - 1);
63
65
  if (openTag !== -1 && cut.indexOf('>', openTag) === -1) {
64
66
  end = openTag;
@@ -68,21 +70,22 @@ export const clampMessage = (text, limit = 4000) => {
68
70
  end = amp;
69
71
  }
70
72
  const body = cut.slice(0, end);
71
- // Кламп мог отрезать закрывающие тегидобираем их, чтобы разметка сошлась.
72
- // blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
73
- // прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
74
- // цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
75
- // `u` в списке с 2026-08-25: заголовок группы рисуется как `<i><u>…</u></i>`,
76
- // и обрезанный посередине длинный заголовок оставлял `<u>` незакрытым.
77
- // Telegram отвечает на такое 400 то есть карточка пропадала целиком, а
78
- // отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
79
- // отчётом с именем группы в 5000 знаков.
73
+ // The clamp may have cut off closing tags we add them back so the markup
74
+ // matches. blockquote — since quoting arrived for notes/details, a long
75
+ // detail gets cut right in the middle of it, and without this tag Telegram
76
+ // would answer 400 on the unclosed quote (the regex `<blockquote[ >]` also
77
+ // catches the variant with an `expandable` attribute).
78
+ // `u` joined the list on 2026-08-25: a group heading renders as
79
+ // `<i><u>…</u></i>`, and a long heading cut in the middle left `<u>` unclosed.
80
+ // Telegram answers 400 to that meaning the whole card disappeared, and the
81
+ // sender with `|| true` never noticed. Codex found it; reproduces with a
82
+ // report whose group name is 5000 characters long.
80
83
  //
81
- // Порядок закрытия обратный порядку ОТКРЫТИЯ, а не фиксированный список.
82
- // Фиксированный список закрывал `<i><u>` как `</i></u>`: тегов поровну,
83
- // счётчик сходится, вложенность нарушена, и Telegram отвечает тем же 400.
84
- // Второй заход того же бага (25.08.2026), поэтому теперь порядок берётся из
85
- // самого текста: последний открытый закрывается первым.
84
+ // Closing order is the reverse of OPENING order, not a fixed list. A fixed
85
+ // list closed `<i><u>` as `</i></u>`: the tag count matches, but the nesting
86
+ // is wrong, and Telegram answers with the same 400. Second round of the same
87
+ // bug (25.08.2026), so now the order comes from the text itself: the last
88
+ // one opened is the first one closed.
86
89
  const open = [];
87
90
  const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
88
91
  for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
@@ -102,12 +105,12 @@ export const clampMessage = (text, limit = 4000) => {
102
105
  .join('');
103
106
  return `${body}${tail}\n…`;
104
107
  };
105
- // Только первая строка: однострочное поле по контракту (коммит, ветка,
106
- // автор, статистика), а не место для абзаца. Живой случай (18.08): CI-карточка
107
- // понесла ПОЛНОЕ тело коммита с историей под-коммитов через `--commit` и вместо
108
- // одной строки развернулась на 3000 символовмногострочный текст либо
109
- // ошибка вызывающего, либо должен идти через `note()`, а не молча раздувать
110
- // карточку.
108
+ // Only the first line: a field is single-line by contract (commit, branch,
109
+ // author, a stat), not a place for a paragraph. A live case (18.08): a CI card
110
+ // carried the FULL commit body with a sub-commit history through `--commit`
111
+ // and instead of one line unrolled to 3000 charactersmulti-line text is
112
+ // either a caller mistake, or it belongs in `note()`, not a silent bloat of
113
+ // the card.
111
114
  const firstLine = (value) => {
112
115
  if (typeof value === 'number' || !value.includes('\n')) {
113
116
  return value;
@@ -115,16 +118,16 @@ const firstLine = (value) => {
115
118
  return `${value.split('\n')[0]}…`;
116
119
  };
117
120
  /**
118
- * Поле: `<b>Ярлык:</b> значение`жирный ярлык с большой буквы, значение
119
- * обычным. `null` отбрасывается наравне с `undefined`/`''` — источники поля
120
- * это JSON со stdin (`--json`) и объекты с сервера, где отсутствующее
121
- * значение сериализуется как `null`, а не как пропущенный ключ.
121
+ * A field: `<b>Label:</b> value`a bold, capitalized label, a plain value.
122
+ * `null` is dropped the same as `undefined`/`''` — the field's sources are
123
+ * JSON on stdin (`--json`) and objects from the server, where a missing
124
+ * value serializes as `null`, not as a missing key.
122
125
  */
123
126
  const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
124
127
  /**
125
- * Поле-идентификатор (`commit:`/`pr:`/`issue:`): значение ссылка, если
126
- * она есть, иначе обычный текст того же поляидентификатор не должен
127
- * пропадать целиком только потому, что вызывающий не передал url.
128
+ * An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
129
+ * one exists, otherwise the plain text of the same field an identifier
130
+ * must not disappear entirely just because the caller did not pass a url.
128
131
  */
129
132
  const fieldLink = (label, url, text) => {
130
133
  if (text === undefined || text === null || text === '') {
@@ -138,41 +141,44 @@ const fieldLink = (label, url, text) => {
138
141
  : field(label, text);
139
142
  };
140
143
  /**
141
- * Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
142
- * полепрогону просто некуда вести, показывать голое слово «run» без
143
- * ссылки бессмысленнее, чем не показывать строку вовсе.
144
+ * An action field (`workflow:`): unlike `fieldLink`, without a URL this is
145
+ * NOT a field the run simply has nowhere to lead, and showing the bare
146
+ * word "run" with no link is more meaningless than not showing the row at
147
+ * all.
144
148
  */
145
- // Текст ссылки имя того, куда она ведёт (имя workflow, имя прогона). Запасное
146
- // слово было «run»: существительное, которое ничего не называетвладелец читал
147
- // «Workflow: run» и не понимал, что это. «open» глагол, он хотя бы честно
148
- // говорит, что это ссылка, а не название.
149
+ // The link text is the name of where it leads (the workflow's name, the run's
150
+ // name). The fallback word used to be "run": a noun that names nothingthe
151
+ // owner read "Workflow: run" and did not understand what it was. "open" is a
152
+ // verb, and it is at least honest about being a link, not a name.
149
153
  const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
150
- /** Моноширинное полепуть/команда для копирования, не ссылка. */
154
+ /** A monospace field a path/command to copy, not a link. */
151
155
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
152
156
  /**
153
- * Строка, которая просит что-то ОТ ВЛАДЕЛЬЦА, а не сообщает факт. Она уже
154
- * стояла последней и через пустую строку, и всё равно читалась как рядовое
155
- * поле среди пяти других. Маркер `▶` единственное отличие: заголовок группы
156
- * здесь был бы третьей строкой разметки на карточку из шести (25.08.2026, два
157
- * ревью против группировки), а маркер не тратит ни одной.
157
+ * A row that asks something FROM THE OWNER, rather than reports a fact. It
158
+ * already stood last, set off by a blank line, and still read as an
159
+ * ordinary field among five others. The `▶` marker is the only difference:
160
+ * a group heading here would have been a third line of markup on a card
161
+ * that already has six (25.08.2026, two reviews against grouping), and the
162
+ * marker spends none.
158
163
  */
159
164
  /**
160
- * Действие: что сделать и чем. Без объяснения команда НЕ печатается вовсе
161
- * владелец на голую `rm` в карточке: «я ж не знаю, что делаю». Молча уронить
162
- * строку лучше, чем показать ему команду, которую он не может прочитать;
163
- * отправителя при этом ловит тест каталога, а не тишина в чате.
165
+ * Action: what to do and with what. Without an explanation the command is
166
+ * NOT printed at all the owner on a bare `rm` in a card: "I don't even
167
+ * know what I'm doing." Silently dropping the row is better than showing
168
+ * him a command he cannot read; the sender is caught by the catalogue test
169
+ * instead, not by silence in the chat.
164
170
  */
165
171
  const fieldRun = (value, why) => {
166
172
  const explain = field('To do', why);
167
- // Без значка. `▶` был единственным символом такого рода на все двадцать
168
- // видов карточек, и владелец справедливо спросил, что он значит: ничего,
169
- // чего не сказала бы строка `To do:` над ним и моноширинный шрифт под ним
170
- // Telegram делает такую строку копируемой по нажатию сам.
173
+ // No icon. `▶` was the only symbol of its kind across all twenty kinds of
174
+ // cards, and the owner rightly asked what it meant: nothing that the row
175
+ // `To do:` above it and the monospace font below it did not already say
176
+ // Telegram makes a row like that tap-to-copy on its own.
171
177
  return value && explain !== null ? [explain, `<code>${esc(value)}</code>`] : [];
172
178
  };
173
- /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
179
+ /** A group heading: italic + underline, no bold, no colon. */
174
180
  const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
175
- /** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
181
+ /** An item inside a group: `<b>label:</b> <a>text</a>` — or a plain bulleted/numbered row with no label. */
176
182
  const groupItem = (it, index, numbered) => {
177
183
  const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
178
184
  if (it.label) {
@@ -180,10 +186,11 @@ const groupItem = (it, index, numbered) => {
180
186
  }
181
187
  return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
182
188
  };
183
- // Длинное пояснение (примечание, детали инцидента) — цитатой: у Telegram это
184
- // полоска слева и лёгкий отступ, читается как «подробности», а не как часть
185
- // заголовка. Длиннее ~400 знаков цитата сворачивается сама (`expandable`,
186
- // Bot API), иначе стектрейс или дамп лога растягивает карточку на весь экран.
189
+ // A long explanation (a note, incident details) — as a quote: in Telegram
190
+ // that is a bar on the left and a light indent, reading as "details," not as
191
+ // part of the heading. Longer than ~400 characters and the quote collapses
192
+ // on its own (`expandable`, Bot API), otherwise a stack trace or a log dump
193
+ // stretches the card across the whole screen.
187
194
  const EXPAND_AT = 400;
188
195
  const note = (text) => {
189
196
  if (!text) {
@@ -193,11 +200,12 @@ const note = (text) => {
193
200
  return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
194
201
  };
195
202
  /**
196
- * Цитата с подписью. Голая цитата читается как продолжение поля над ней:
197
- * владелец спросил про строку, которой открыта сессия, «что значит этот текст,
198
- * откуда он берётся» и был прав, в карточке это нигде не сказано. Подпись
199
- * стоит отдельной строкой, потому что сам текст в поле не помещается: поле
200
- * держит одну строку и обрезает.
203
+ * A quote with a caption. A bare quote reads as a continuation of the
204
+ * field above it: the owner asked about the line that opens a session,
205
+ * "what does this text mean, where does it come from" and he was right,
206
+ * the card says it nowhere. The caption stands on its own line, because
207
+ * the text itself does not fit in a field: a field holds one line and cuts
208
+ * it.
201
209
  */
202
210
  /**
203
211
  * A quote that needs saying what it is. The heading is a GROUP heading — the
@@ -208,10 +216,10 @@ const note = (text) => {
208
216
  */
209
217
  const quoted = (label, text) => text ? `${group(label)}\n${note(text)}` : null;
210
218
  /**
211
- * Склейка карточки. Пустая строка здесь знак смены блока, а не отступ:
212
- * две подряд означают пустой блок, ведущая блок, которого нет. Обе
213
- * появляются, когда часть полей не пришла, и обе схлопываются тут, а не
214
- * в каждом рендерере по отдельности.
219
+ * Assembles the card. A blank line here marks a block change, not
220
+ * indentation: two in a row mean an empty block, a leading one means a
221
+ * block that does not exist. Both appear when some fields did not arrive,
222
+ * and both collapse here, not separately in every renderer.
215
223
  */
216
224
  const join = (parts) => {
217
225
  const out = [];
@@ -229,12 +237,13 @@ const join = (parts) => {
229
237
  }
230
238
  return out.join('\n');
231
239
  };
232
- /** Плоский список позиций (без ярлыков) — job/report без групп. */
240
+ /** A flat list of items (no labels) — job/report with no groups. */
233
241
  /**
234
- * Позиции списка. Именованная группа ВСЕГДА печатает свой заголовоктот же
235
- * закон, что у `labelled`: карточка одного типа не должна выглядеть по-разному
236
- * в разные дни. Нумерация идёт внутри блока, а не сквозная: «1, 2» под своим
237
- * заголовком читается, сквозная «3, 4» под вторым нет.
242
+ * List items. A named group ALWAYS prints its heading the same law
243
+ * `labelled` follows: a card of one type must not look different on
244
+ * different days. Numbering runs within the block, not across it: "1, 2"
245
+ * under its own heading reads fine, a running "3, 4" under the second one
246
+ * does not.
238
247
  */
239
248
  const bullets = (items, numbered) => {
240
249
  const list = items ?? [];
@@ -251,42 +260,45 @@ const bullets = (items, numbered) => {
251
260
  }
252
261
  return out;
253
262
  };
254
- /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
263
+ /** A whole named group: heading + items, separated by a blank line inside the call, via join. */
255
264
  const renderGroup = (g) => [
256
265
  group(g.name),
257
266
  ...g.items.map((it, i) => groupItem(it, i, false))
258
267
  ];
259
268
  /**
260
- * ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок
261
- * обычное поле `Title:`, тело цитата, и в цитате больше ничего нет.
269
+ * ONE rule for every card that has both a title and a body: the title is
270
+ * an ordinary `Title:` field, the body is a quote, and the quote holds
271
+ * nothing else.
262
272
  *
263
- * Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
264
- * строкой. Владелец нашёл, чем это плохо: заголовок главное в карточке, то,
265
- * ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
266
- * одно от другого можно было только по пустой строке. У PR без тела карточка
267
- * вырождалась в одинокую серую цитату из одной строки.
273
+ * The title used to sit INSIDE THE QUOTE together with the body, separated
274
+ * by a blank line. The owner found the problem with that: the title is the
275
+ * main thing on a card, WHAT it is about, and it sat there as gray text of
276
+ * the same weight as the description the only way to tell them apart was
277
+ * the blank line. On a PR with no body, the card degenerated into a single
278
+ * lonely gray one-line quote.
268
279
  *
269
- * Заголовок режется до первой строки: многострочный subject коммита не должен
270
- * затягивать в поле собственное тело.
280
+ * The title is cut to its first line: a multi-line commit subject must not
281
+ * drag its own body into the field.
271
282
  */
272
283
  const titleField = (title) => field('Title', title);
273
284
  const bodyQuote = (body) => body ? note(body) : null;
274
285
  /**
275
- * Строки с ярлыками, разложенные по группам, которые назвал сам отправитель.
286
+ * Labelled rows, sorted into the groups the sender itself named.
276
287
  *
277
- * Закон простой и считается программой: назвал группузаголовок печатается.
278
- * Всегда, сколько бы строк в ней ни было и сколько бы групп ни оказалось.
279
- * Порог «две и больше» я пробовал и снял: у карточки резервных копий все
280
- * цифры лежат в одной группе, а над ними рассказ о прогоне, и порог гасил
281
- * ровно тот шов, ради которого владелец всё это и просил.
288
+ * The rule is simple and enforced by code: named a group the heading
289
+ * prints. Always, no matter how many rows are in it or how many groups
290
+ * turn up. I tried a "two or more" threshold and dropped it: on the backup
291
+ * card all the numbers sit in one group, with a summary of the run above
292
+ * them, and the threshold was killing exactly the seam the owner asked
293
+ * for in the first place.
282
294
  *
283
- * Так же уходит и риск «одна и та же карточка выглядит по-разному в разные
284
- * дни»: вид зависит от того, что отправитель НАЗВАЛ в коде, а не от того,
285
- * сколько строк набралось сегодня.
295
+ * This also removes the risk of "the same card looks different on
296
+ * different days": the look depends on what the sender NAMED in code, not
297
+ * on how many rows happened to show up today.
286
298
  *
287
- * Порядок групп порядок первого появления у отправителя: он знает, что
288
- * важнее. Строки без имени идут первыми и без заголовкаэто факты о самой
289
- * карточке, а не о каком-то из её предметов.
299
+ * Group order is the order the sender first mentions them: it knows what
300
+ * matters more. Unnamed rows come first with no headingthey are facts
301
+ * about the card itself, not about any one of its subjects.
290
302
  */
291
303
  const labelled = (rows) => {
292
304
  const list = rows ?? [];
@@ -303,10 +315,10 @@ const labelled = (rows) => {
303
315
  }
304
316
  }
305
317
  for (const name of names) {
306
- // Пустая строка перед КАЖДЫМ заголовком, включая первый: над ним всегда
307
- // стоят поля самой карточки (Task, Period), и без шва заголовок читался
308
- // как ещё одна их строка. Двойных пустот бояться не нужно их схлопывает
309
- // `join`.
318
+ // A blank line before EVERY heading, including the first: above it there
319
+ // are always the card's own fields (Task, Period), and without the seam
320
+ // the heading read as just another one of them. No need to worry about
321
+ // double blanks — `join` collapses them.
310
322
  out.push('');
311
323
  out.push(group(name));
312
324
  for (const [label, value] of list.filter(([, , g]) => g === name)) {
@@ -319,18 +331,19 @@ const labelled = (rows) => {
319
331
  return out;
320
332
  };
321
333
  /**
322
- * Блоки, которыми владеет сам рендерер,у выкатки и проверки их два, и они
323
- * про разные вещи: `Run` это сам прогон и его обстоятельства, `Change` это
324
- * изменение, из-за которого он случился. Владелец на CI-карточке: «commit,
325
- * actor, workflow — не знаю, всё так сумбурно».
334
+ * Blocks owned by the renderer itself a deploy and a check have two of
335
+ * them, and they are about different things: `Run` is the run itself and
336
+ * its circumstances, `Change` is the change that caused it. The owner on a
337
+ * CI card: "commit, actor, workflow — I don't know, it's all a jumble."
326
338
  *
327
- * Заголовок печатается у КАЖДОГО непустого блока, а не только когда их два.
328
- * Сначала было «два и больше», ради экономии строки на зелёной карточке, и
329
- * это оказалось ошибкой: у зелёной выкатки нет ни цели, ни причины, блок один,
330
- * заголовки пропадали и один и тот же вид уведомления выглядел в разные дни
331
- * по-разному. Владелец дважды спросил «почему здесь нет групп», глядя именно
332
- * на зелёную. Строка заголовка стоит дешевле, чем необходимость каждый раз
333
- * заново искать глазами, где что.
339
+ * The heading prints for EVERY non-empty block, not only when there are
340
+ * two. It used to be "two or more," to save a line on a green card, and
341
+ * that turned out to be a mistake: a green deploy has no target and no
342
+ * reason, there is only one block, the headings disappeared and the same
343
+ * kind of notification looked different from one day to the next. The
344
+ * owner asked twice "why isn't there a group here," looking straight at a
345
+ * green one. A heading row costs less than having to hunt for what is what
346
+ * every single time.
334
347
  */
335
348
  /**
336
349
  * A deploy or a check has two subjects: the run itself and the commit it went
@@ -349,11 +362,12 @@ const twoBlocks = (run, change) => {
349
362
  const changeRows = live(change);
350
363
  return [...runRows, ...(changeRows.length > 0 ? ['', group('Change'), ...changeRows] : [])];
351
364
  };
352
- // Значок и его закон живут в events.ts: от него зависит и звук.
353
- /** Строка 2: значок вне жирного, `<b>Тип:</b> действие`то же поле, не особый случай. */
354
- // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
355
- // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
356
- // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
365
+ // The icon and its rule live in events.ts: the sound depends on it too.
366
+ /** Line 2: the icon sits outside the bold, `<b>Type:</b> action`the same field, not a special case. */
367
+ // `action` is typed as a string, but it also arrives from `--json` and from
368
+ // direct calls in JS, where there are no types. An empty or missing value
369
+ // produced the row `ℹ️ null` right on the card's second line. An empty string
370
+ // is more honest: the field simply disappears.
357
371
  // A link belongs on the NAME of the thing it opens, never on a separate row
358
372
  // whose only text is the verb `open`. The owner read `Details: open` under a
359
373
  // report and asked what "open" was — the answer is the report itself, which was
@@ -375,11 +389,12 @@ const typeLine = (icon, type, action, url, aside) => {
375
389
  const tail = aside ? ` (${esc(aside)})` : '';
376
390
  return line === null ? `${icon} <b>${esc(cap(type))}</b>${tail}` : `${icon} ${line}${tail}`;
377
391
  };
378
- // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
379
- // `--url` — это имя было в пакете раньше и осталось в вызовах. Рендер читал
380
- // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
381
- // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
382
- // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
392
+ // `workflowUrl ?? url`: half the senders send the run link under the name
393
+ // `--url` — that name was in the package before and stayed in their calls.
394
+ // The renderer only read `workflowUrl`, so a red card arrived WITH NOT A
395
+ // SINGLE LINK to the logs. Rejecting `--url` would be more honest by name and
396
+ // worse in practice: the intent is unambiguous, and a card with no link is
397
+ // useless exactly when it is needed most.
383
398
  /**
384
399
  * What to call the thing that ran. The workflow's own name first — it is the
385
400
  * only text here that identifies THIS run. Then the caller's own word for the
@@ -407,13 +422,18 @@ const renderDeploy = (e) => {
407
422
  const icon = iconFor(e);
408
423
  const runUrl = e.workflowUrl ?? e.url;
409
424
  return join([
410
- // Имя того, чем выкатили, на строке типа. Исход говорят значок и третий
411
- // тег; повторять его словом нечего, и это тот же закон, что у задачи и у
412
- // отчёта. Строки `Via` больше нет: она несла это имя этажом ниже.
425
+ // The name of what shipped the deploy sits on the type line. The outcome
426
+ // is already said by the icon and the third tag; there is nothing to
427
+ // repeat in words, and it is the same law a job and a report follow. The
428
+ // `Via` row is gone: it used to carry this same name one floor below.
413
429
  typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl) ?? e.status, runUrl),
414
430
  ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
415
431
  ]);
416
432
  };
433
+ const schedule = (expected, lastSeen, lastLabel) => {
434
+ const rows = [field('Expected', expected), field(lastLabel, lastSeen)].filter((r) => r !== null);
435
+ return rows.length > 0 ? ['', group('Schedule'), ...rows] : [];
436
+ };
417
437
  const renderJob = (e) => {
418
438
  const icon = iconFor(e);
419
439
  const hasItems = (e.items ?? []).length > 0;
@@ -429,12 +449,13 @@ const renderJob = (e) => {
429
449
  // icon says and what the third tag now says too. The icon table on the
430
450
  // catalogue page defines both marks.
431
451
  return join([
432
- typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url),
452
+ typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url, e.aside),
433
453
  field('Reason', e.note),
434
- field('Expected', e.expected),
435
- // `Last run` when the task is alive, `Last seen` when it is not: the same
436
- // timestamp answers two different questions.
437
- field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
454
+ // The timetable is a different subject from this event: how often the task
455
+ // owes a sign of life and when it last gave one. It stood in a bare run
456
+ // under `Reason:` and read as more of the same. `Last run` when the task
457
+ // is alive, `Last seen` when it is not — one timestamp, two questions.
458
+ ...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
438
459
  ...labelled(e.stats),
439
460
  hasItems ? '' : null,
440
461
  // Heading ONLY for `disabled`. It used to print for any job carrying a
@@ -459,7 +480,7 @@ const renderReport = (e) => {
459
480
  // without a word.
460
481
  const numbers = labelled(e.lines);
461
482
  return join([
462
- typeLine(iconFor(e), 'Report', e.title, e.url, e.period),
483
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
463
484
  // Rows with no group of their own sit flush against the header instead of
464
485
  // forming a separate slab under a blank line. `labelled` puts the blank
465
486
  // line before the first group itself, so there is none here.
@@ -472,7 +493,7 @@ const renderReport = (e) => {
472
493
  return join([
473
494
  // Both analytics jobs send a link to the day's snapshot in docs/. It used to
474
495
  // hang off a trailing `Details: open` row; now it is the report's own name.
475
- typeLine(iconFor(e), 'Report', e.title, e.url, e.period),
496
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
476
497
  // Flush against the header — see the branch above.
477
498
  ...labelled(e.lines),
478
499
  items.length > 0 ? '' : null,
@@ -498,17 +519,28 @@ const renderCi = (e) => {
498
519
  // The action is not repeated in words: the icon carries it, and no two actions
499
520
  // of one type share an icon.
500
521
  const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
522
+ // The people come BEFORE the text, and the text comes only when it is the
523
+ // news. An `assigned` card carries one new fact — who took it — and it used to
524
+ // sit dead last, under the issue's entire description: the owner read a card
525
+ // about someone taking issue #312 and asked who, because he never got that far.
526
+ //
527
+ // The description is the news exactly once, when the thing is opened. On
528
+ // assigned, closed, merged or a review verdict it is text he has already read,
529
+ // and it buries the one line he came for.
530
+ const opening = (action, body) => action === 'opened' ? bodyQuote(body) : null;
501
531
  const renderPr = (e) => join([
502
532
  typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
503
- bodyQuote(e.body),
504
533
  field('Author', e.author),
505
- field('Reviewer', e.reviewer)
534
+ field('Reviewer', e.reviewer),
535
+ e.action === 'opened' && e.body ? '' : null,
536
+ opening(e.action, e.body)
506
537
  ]);
507
538
  const renderIssue = (e) => join([
508
539
  typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
509
- bodyQuote(e.body),
510
540
  field('Author', e.author),
511
- field('Assignee', e.assignee)
541
+ field('Assignee', e.assignee),
542
+ e.action === 'opened' && e.body ? '' : null,
543
+ opening(e.action, e.body)
512
544
  ]);
513
545
  // The incident's own title IS line 2, exactly as an issue's is. It used to say
514
546
  // the word `open` there — which the 🚨 already says, and no other card repeats
@@ -555,8 +587,7 @@ const renderHeartbeatMiss = (e) => {
555
587
  // copy of that watchdog can, and the card it gets must obey the template.
556
588
  typeLine(icon, 'Heartbeat', e.job, undefined, action),
557
589
  field('Reason', e.note),
558
- field('Expected', e.expected),
559
- field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
590
+ ...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
560
591
  ]);
561
592
  };
562
593
  const RENDERERS = {
@@ -570,21 +601,22 @@ const RENDERERS = {
570
601
  session: renderSession,
571
602
  heartbeat_miss: renderHeartbeatMiss
572
603
  };
573
- // Тег наверху карточки И машинный ключ разборщика ОДНО И ТО ЖЕ значение
574
- // (решение владельца 20.08.2026): раньше это были два разных представления
575
- // одного факта (снизу дефисный `#ci-arvent`, сверху теги вручную), и это
576
- // читалось как дублирование. Разделитель подчёркивание, не дефис: дефис
577
- // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
578
- // только как `#mac`), а тег ДОЛЖЕН быть кликабельным это и есть фильтр
579
- // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
604
+ // The tag at the top of the card AND the parser's machine key are ONE AND THE
605
+ // SAME value (the owner's decision, 20.08.2026): they used to be two separate
606
+ // representations of one fact (a hyphenated `#ci-arvent` at the bottom, tags
607
+ // typed by hand at the top), and that read as duplication. The separator is
608
+ // an underscore, not a hyphen: a hyphen splits a Telegram hashtag in the
609
+ // middle of a word (`#mac-config` links only as `#mac`), and the tag MUST be
610
+ // clickable that is exactly the "show this instance's whole history" filter
611
+ // the owner uses in practice.
580
612
  export const slug = (raw) => raw
581
613
  .toLowerCase()
582
614
  .replace(/[^\p{L}\p{N}]+/gu, '_')
583
615
  .replace(/^_+|_+$/g, '')
584
616
  .slice(0, 60);
585
- // Тип-тег наверху не буквальный `e.type`: `heartbeat_miss` читался бы как
586
- // `#heartbeat_miss`, а видимый тип у владельца всегда просто `#heartbeat`
587
- // (зелёная и красная карточки одного вида один и тот же тип-тег).
617
+ // The type tag at the top is not the literal `e.type`: `heartbeat_miss` would
618
+ // read as `#heartbeat_miss`, while the type the owner sees is always just
619
+ // `#heartbeat` (a green and a red card of one kind carry the same type tag).
588
620
  const TYPE_TAG = {
589
621
  deploy: 'deploy',
590
622
  job: 'job',
@@ -597,12 +629,13 @@ const TYPE_TAG = {
597
629
  heartbeat_miss: 'heartbeat'
598
630
  };
599
631
  /**
600
- * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
601
- * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
602
- * карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
603
- * выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
604
- * потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
605
- * заголовок и есть единственное стабильное поле).
632
+ * The instance tag: exactly which concrete event this is (branch,
633
+ * environment, task, number) — the parser uses it to match a 🔴 against a
634
+ * later green card of the SAME instance. An explicit `key` always wins;
635
+ * without one, it is derived from the type's most stable fields (branch/
636
+ * environment outrank the title, because a recurring task's title does not
637
+ * change, while for a report the title is exactly the one stable field it
638
+ * has).
606
639
  */
607
640
  export const eventKey = (e) => {
608
641
  const fallback = () => {
@@ -630,10 +663,11 @@ export const eventKey = (e) => {
630
663
  return e.key ? slug(e.key) : fallback();
631
664
  };
632
665
  /**
633
- * Третий тег ИСХОД, и он есть всегда. Владелец: «не хватает тега fail или
634
- * похожего, чтобы фейлы можно было группировать и ок можно было группировать».
635
- * Одно нажатие в Telegram собирает все падения проекта разом, каким бы типом
636
- * они ни пришли выкатка, проверка, задача по расписанию, авария.
666
+ * The third tag is the OUTCOME, and it is always there. The owner: "I'm
667
+ * missing a fail tag or something like it, so failures can be grouped and
668
+ * ok can be grouped." One tap in Telegram collects every failure of a
669
+ * project at once, no matter what type it arrived as — a deploy, a check,
670
+ * a scheduled task, an incident.
637
671
  *
638
672
  * The value comes from the ICON, never from the status word. The icon is
639
673
  * already the single source of truth for the sound, and a second list of "what
@@ -659,33 +693,34 @@ export const OUTCOME_TAG = {
659
693
  export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)] ?? 'news';
660
694
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
661
695
  /**
662
- * Строка тегов для свободного HTML (`sendReport`). Тег это ФИЛЬТР владельца,
663
- * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
664
- * текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
665
- * висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
666
- * теги переехали первой строкой.
696
+ * The tag line for free-form HTML (`sendReport`). The tag is the owner's
697
+ * FILTER, and it has nothing to do with the body's format: a daily report
698
+ * stays free-form text, but stops being the one card with no tags. The key
699
+ * used to hang off the tail as `<i><code>#key</code></i>` — that is the old
700
+ * format, from before tags moved to the first line.
667
701
  */
668
702
  export const reportTags = (key) => `#report #${esc(slug(key))}`;
669
703
  /**
670
- * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
671
- * Теги ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
672
- * несут и человеческий фильтр, и машинный ключ разборщикаобрезанная
673
- * карточка без них была бы не только некликабельной, но и невидимой
674
- * разборщику ровно на самых длинных, то есть самых важных сообщениях.
704
+ * Renders an event into finished HTML text, cut to Telegram's limit.
705
+ * Tags are the FIRST line, added before the cut (not after, as before):
706
+ * they carry both the human filter and the parser's machine key a card
707
+ * cut without them would be not only unclickable but invisible to the
708
+ * parser on exactly the longest, meaning the most important, messages.
675
709
  */
676
710
  export const render = (e) => {
677
711
  const renderer = RENDERERS[e.type];
678
- // Прикрывает путь `--json` и вызовы из JS без типов: там `type` — обычная
679
- // строка, и неизвестное значение роняло процесс через `renderer is not a
680
- // function`. Падать из-за уведомления нельзя.
712
+ // Covers the `--json` path and calls from JS with no types: there `type`
713
+ // is a plain string, and an unknown value crashed the process through
714
+ // `renderer is not a function`. A notification must never crash anything.
681
715
  if (typeof renderer !== 'function') {
682
716
  throw new Error(`unknown event type: ${String(e.type)}`);
683
717
  }
684
718
  const tags = tagsLine(e);
685
- // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
686
- // многоточиеминус 40 оставляет ему этот запас. У сообщений свой запас уже
687
- // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
688
- // Карточка с вложением это подпись, поэтому бюджет выбирается по `path`.
719
+ // clampMessage can go past the passed limit for the tail of closing tags
720
+ // and the ellipsis minus 40 leaves it that margin. Messages already have
721
+ // their own margin (4000 against Telegram's 4096); for a caption the 1024
722
+ // limit is the real one. A card with an attachment is a caption, so the
723
+ // budget is chosen by `path`.
689
724
  const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
690
725
  return `${tags}\n${clampMessage(renderer(e), budget)}`;
691
726
  };