@mikitasazan/notify 1.2.0 → 1.3.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.
package/dist/cli.js CHANGED
@@ -39,7 +39,7 @@ if (command === 'setup') {
39
39
  const flags = new Map();
40
40
  const parseErrors = [];
41
41
  /** Флаги без значения. Всё остальное обязано его иметь. */
42
- const BOOLEAN_FLAGS = new Set(['json']);
42
+ const BOOLEAN_FLAGS = new Set(['json', 'recovered']);
43
43
  for (let i = 1; i < args.length; i++) {
44
44
  const arg = args[i];
45
45
  if (!arg.startsWith('--')) {
@@ -139,6 +139,15 @@ const status = () => {
139
139
  const raw = (one('status') ?? '').toLowerCase();
140
140
  return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
141
141
  };
142
+ // `job` — единственный тип с третьим состоянием (`disabled`): задача не
143
+ // провалилась сама, её выключил кто-то извне (GitHub Actions без минут).
144
+ const jobStatus = () => {
145
+ const raw = (one('status') ?? '').toLowerCase();
146
+ if (raw === 'disabled') {
147
+ return 'disabled';
148
+ }
149
+ return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
150
+ };
142
151
  let event;
143
152
  if (flags.has('json')) {
144
153
  try {
@@ -160,6 +169,10 @@ else {
160
169
  status: status(),
161
170
  commit: one('commit'),
162
171
  commitUrl: one('commit-url'),
172
+ commitTitle: one('commit-title'),
173
+ commitBody: one('commit-body'),
174
+ workflowUrl: one('workflow-url'),
175
+ workflowName: one('workflow-name'),
163
176
  url: one('url'),
164
177
  target: one('target'),
165
178
  via: one('via'),
@@ -171,10 +184,12 @@ else {
171
184
  type: 'job',
172
185
  project: project(),
173
186
  job: one('job') ?? '(без имени)',
174
- status: status(),
187
+ status: jobStatus(),
175
188
  stats: pairs('stat'),
176
189
  items: items(),
177
190
  note: one('note'),
191
+ workflowUrl: one('workflow-url'),
192
+ workflowName: one('workflow-name'),
178
193
  url: one('url')
179
194
  };
180
195
  break;
@@ -196,7 +211,12 @@ else {
196
211
  status: status(),
197
212
  branch: one('branch'),
198
213
  commit: one('commit'),
214
+ commitUrl: one('commit-url'),
215
+ commitTitle: one('commit-title'),
216
+ commitBody: one('commit-body'),
199
217
  actor: one('actor'),
218
+ workflowUrl: one('workflow-url'),
219
+ workflowName: one('workflow-name'),
200
220
  url: one('url')
201
221
  };
202
222
  break;
@@ -219,6 +239,7 @@ else {
219
239
  action: issueAction(one('action')),
220
240
  number: num('number'),
221
241
  title: one('title') ?? '(без заголовка)',
242
+ body: one('body'),
222
243
  author: one('author'),
223
244
  assignee: one('assignee'),
224
245
  url: one('url')
@@ -230,6 +251,7 @@ else {
230
251
  project: project(),
231
252
  title: one('title') ?? '(без заголовка)',
232
253
  detail: one('detail'),
254
+ logs: one('logs'),
233
255
  url: one('url')
234
256
  };
235
257
  break;
@@ -239,7 +261,9 @@ else {
239
261
  project: project(),
240
262
  job: one('job') ?? '(без имени)',
241
263
  lastSeen: one('last-seen'),
242
- expected: one('expected')
264
+ expected: one('expected'),
265
+ recovered: flags.has('recovered'),
266
+ note: one('note')
243
267
  };
244
268
  break;
245
269
  case 'file': {
package/dist/events.d.ts CHANGED
@@ -29,9 +29,16 @@ type Keyed = {
29
29
  * Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
30
30
  * замечание. `url` необязателен — тогда рендерится просто строкой.
31
31
  */
32
+ /**
33
+ * `label` — необязательный жирный префикс перед `text` (`#243 (overdue)`,
34
+ * `#287`) для позиций внутри именованных групп отчёта. Без `label` позиция
35
+ * рендерится как обычная нумерованная/маркированная строка — так уже
36
+ * работают дайджест-задачи и список выключенных workflow.
37
+ */
32
38
  export type Item = {
33
39
  text: string;
34
40
  url?: string;
41
+ label?: string;
35
42
  };
36
43
  export type NotifyEvent = Keyed & (
37
44
  /** Выкатка кода на сервер. */
@@ -42,6 +49,13 @@ export type NotifyEvent = Keyed & (
42
49
  commit?: string;
43
50
  /** Ссылка на коммит — строка «коммит» становится кликабельной. */
44
51
  commitUrl?: string;
52
+ /** Заголовок коммита — рендерится рядом с телом в цитате. */
53
+ commitTitle?: string;
54
+ /** Тело коммита, если есть — та же цитата, что и заголовок. */
55
+ commitBody?: string;
56
+ workflowUrl?: string;
57
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
58
+ workflowName?: string;
45
59
  url?: string;
46
60
  /**
47
61
  * Куда выкатили. Заполнять ТОЛЬКО когда окружений больше одного: у сайтов
@@ -64,11 +78,15 @@ export type NotifyEvent = Keyed & (
64
78
  type: 'job';
65
79
  project: Project;
66
80
  job: string;
67
- status: 'ok' | 'fail';
81
+ /** `disabled` задача выключена извне (например GitHub Actions кончил бесплатные минуты), не провалилась сама. */
82
+ status: 'ok' | 'fail' | 'disabled';
68
83
  stats?: Array<[label: string, value: string | number]>;
69
- /** Детали: что именно упало, замечания прогона. */
84
+ /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
70
85
  items?: Item[];
71
86
  note?: string;
87
+ workflowUrl?: string;
88
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
89
+ workflowName?: string;
72
90
  url?: string;
73
91
  }
74
92
  /** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
@@ -77,13 +95,24 @@ export type NotifyEvent = Keyed & (
77
95
  project: Project;
78
96
  title: string;
79
97
  period?: string;
80
- lines: Array<[label: string, value: string | number]>;
98
+ /** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
99
+ lines?: Array<[label: string, value: string | number]>;
81
100
  /**
82
101
  * Список позиций со ссылками — для дайджестов задач, где ценность в
83
102
  * самих названиях, а не в цифре. Рендерятся отдельным блоком после
84
103
  * `lines`.
85
104
  */
86
105
  items?: Item[];
106
+ /**
107
+ * Именованные группы (доска задач: Ready/In Progress/Not on the
108
+ * board; аналитика: Metrics/Links) — каждая со своим заголовком и
109
+ * списком позиций. Заменяет `lines`/`items`, когда задан: разные
110
+ * отчёты используют либо плоский вид, либо группы, не оба разом.
111
+ */
112
+ groups?: Array<{
113
+ name: string;
114
+ items: Item[];
115
+ }>;
87
116
  url?: string;
88
117
  }
89
118
  /** Итог CI на основной ветке. */
@@ -93,7 +122,17 @@ export type NotifyEvent = Keyed & (
93
122
  status: 'ok' | 'fail';
94
123
  branch?: string;
95
124
  commit?: string;
125
+ /** Ссылка на коммит — хэш становится кликабельным. */
126
+ commitUrl?: string;
127
+ /** Заголовок коммита (subject) — рендерится в цитате вместе с телом. */
128
+ commitTitle?: string;
129
+ /** Тело коммита (после subject) — та же цитата, что и заголовок. */
130
+ commitBody?: string;
96
131
  actor?: string;
132
+ /** Ссылка на прогон (workflow run) — отдельно от `url`, который у CI не используется. */
133
+ workflowUrl?: string;
134
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
135
+ workflowName?: string;
97
136
  url?: string;
98
137
  }
99
138
  /**
@@ -118,6 +157,8 @@ export type NotifyEvent = Keyed & (
118
157
  action: 'opened' | 'assigned' | 'closed';
119
158
  number: number;
120
159
  title: string;
160
+ /** Тело задачи — рендерится в цитате вместе с заголовком. */
161
+ body?: string;
121
162
  author?: string;
122
163
  assignee?: string;
123
164
  url?: string;
@@ -128,6 +169,8 @@ export type NotifyEvent = Keyed & (
128
169
  project: Project;
129
170
  title: string;
130
171
  detail?: string;
172
+ /** Локальный путь к логам (не URL — рендерится моноширинным, для копирования, не для клика). */
173
+ logs?: string;
131
174
  url?: string;
132
175
  }
133
176
  /** Задача не отметилась вовремя — сторож молчания (heartbeat). */
@@ -137,6 +180,10 @@ export type NotifyEvent = Keyed & (
137
180
  job: string;
138
181
  lastSeen?: string;
139
182
  expected?: string;
183
+ /** Задача снова отчиталась — тот же тип, зелёная карточка вместо красной, ключ (для сверки) не меняется. */
184
+ recovered?: boolean;
185
+ /** Готовое предложение-причина; без него собирается из lastSeen/expected. */
186
+ note?: string;
140
187
  }
141
188
  /**
142
189
  * Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
package/dist/events.js CHANGED
@@ -11,10 +11,17 @@
11
11
  */
12
12
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
13
13
  export const severity = (e) => {
14
- if (e.type === 'incident' || e.type === 'heartbeat_miss') {
14
+ if (e.type === 'heartbeat_miss') {
15
+ return e.recovered ? 'info' : 'error';
16
+ }
17
+ if (e.type === 'incident') {
15
18
  return 'error';
16
19
  }
17
- if ('status' in e && e.status === 'fail') {
20
+ // `disabled` рисуется красным (`ICON.red` в render.ts) ровно как `fail`
21
+ // задача не работает, что бы ни было тому причиной. Молчаливая отправка
22
+ // красной карточки без звука хуже отсутствия карточки: авария выглядит
23
+ // аварией, но не будит (тот же довод, что уже был у `fail`).
24
+ if ('status' in e && (e.status === 'fail' || e.status === 'disabled')) {
18
25
  return 'error';
19
26
  }
20
27
  return 'info';
package/dist/render.d.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  /**
2
- * Один рендерер на тип события, все по одному каркасу:
2
+ * Один рендерер на тип события, все по одному каркасу — утверждён
3
+ * владельцем 20.08.2026 после ~15 живых раундов в тестовом форуме:
3
4
  *
4
- * эмодзи Заголовок · проект
5
- * ключ: значение
6
- * ключ: значение
7
- * <a href="…">Ссылка</a>
5
+ * #тип #экземпляр
6
+ * значок <b>Тип:</b> действие
8
7
  *
9
- * Проект указывается ВСЕГДА, даже в теме самого проекта — в теме
10
- * `🔴 incidents` сообщения четырёх проектов лежат вперемешку, и формат
11
- * должен быть один и тот же независимо от того, куда сообщение попало.
8
+ * <b>Ярлык:</b> значение
9
+ * <blockquote>цитата чужого текста тело коммита, тело задачи</blockquote>
10
+ *
11
+ * <i><u>Группа</u></i>
12
+ * <b>#N (overdue):</b> <a>заголовок</a>
13
+ *
14
+ * <b>Ярлык:</b> значение ← действия/направления
15
+ *
16
+ * Три уровня начертания, никогда не смешиваются: поле — жирный ярлык с
17
+ * большой буквы + обычное значение; группа — курсив+подчёркивание, без
18
+ * жирности и без двоеточия; строка 2 (тип) — тот же закон поля. Пустая
19
+ * строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
20
+ * после каждой строки.
12
21
  */
13
22
  import type { NotifyEvent } from './events.ts';
14
23
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
@@ -27,11 +36,20 @@ export declare const esc: (v: unknown) => string;
27
36
  * повторяем — сообщение исчезало совсем.
28
37
  */
29
38
  export declare const clampMessage: (text: string, limit?: number) => string;
39
+ /**
40
+ * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
41
+ * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
42
+ * карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
43
+ * выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
44
+ * потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
45
+ * заголовок и есть единственное стабильное поле).
46
+ */
30
47
  export declare const eventKey: (e: NotifyEvent) => string;
31
48
  /**
32
49
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
33
- * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
34
- * карточка без ключа была бы невидима разборщикуровно на самых длинных,
35
- * то есть самых важных сообщениях.
50
+ * Теги ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
51
+ * несут и человеческий фильтр, и машинный ключ разборщика обрезанная
52
+ * карточка без них была бы не только некликабельной, но и невидимой
53
+ * разборщику ровно на самых длинных, то есть самых важных сообщениях.
36
54
  */
37
55
  export declare const render: (e: NotifyEvent) => string;
package/dist/render.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** Первая буква — заглавная, остальное как есть (ga4/GitHub остаются собой). */
2
+ const cap = (s) => (s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s);
1
3
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
2
4
  export const esc = (v) => String(v ?? '')
3
5
  .replace(/&/g, '&amp;')
@@ -49,12 +51,54 @@ export const clampMessage = (text, limit = 4000) => {
49
51
  .join('');
50
52
  return `${body}${tail}\n…`;
51
53
  };
52
- const header = (icon, title, project) => `${icon} <b>${esc(title)}</b> · ${esc(project)}`;
53
- // Тире, не жирное значение: сплошной жирный текст в первой версии карточки
54
- // читался как крик (жалоба владельца 18.08). Иконка-лид уже держит внимание
55
- // на заголовке, факты идут построчно и без выделения глаз сам находит
56
- // цифру рядом с меткой.
57
- const kv = (label, value) => value === undefined || value === '' ? null : `${esc(label)} — ${esc(value)}`;
54
+ // Только первая строка: однострочное поле по контракту (коммит, ветка,
55
+ // автор, статистика), а не место для абзаца. Живой случай (18.08): CI-карточка
56
+ // понесла ПОЛНОЕ тело коммита с историей под-коммитов через `--commit` и вместо
57
+ // одной строки развернулась на 3000 символовмногострочный текст либо
58
+ // ошибка вызывающего, либо должен идти через `note()`, а не молча раздувать
59
+ // карточку.
60
+ const firstLine = (value) => {
61
+ if (typeof value === 'number' || !value.includes('\n')) {
62
+ return value;
63
+ }
64
+ return `${value.split('\n')[0]}…`;
65
+ };
66
+ /**
67
+ * Поле: `<b>Ярлык:</b> значение` — жирный ярлык с большой буквы, значение
68
+ * обычным. `null` отбрасывается наравне с `undefined`/`''` — источники поля
69
+ * это JSON со stdin (`--json`) и объекты с сервера, где отсутствующее
70
+ * значение сериализуется как `null`, а не как пропущенный ключ.
71
+ */
72
+ const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
73
+ /**
74
+ * Поле-идентификатор (`commit:`/`pr:`/`issue:`): значение — ссылка, если
75
+ * она есть, иначе обычный текст того же поля — идентификатор не должен
76
+ * пропадать целиком только потому, что вызывающий не передал url.
77
+ */
78
+ const fieldLink = (label, url, text) => {
79
+ if (text === undefined || text === null || text === '') {
80
+ return null;
81
+ }
82
+ return url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text)}</a>` : field(label, text);
83
+ };
84
+ /**
85
+ * Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
86
+ * поле — прогону просто некуда вести, показывать голое слово «run» без
87
+ * ссылки бессмысленнее, чем не показывать строку вовсе.
88
+ */
89
+ const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'run')}</a>` : null;
90
+ /** Моноширинное поле — путь/команда для копирования, не ссылка. */
91
+ const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
92
+ /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
93
+ const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
94
+ /** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
95
+ const groupItem = (it, index, numbered) => {
96
+ const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
97
+ if (it.label) {
98
+ return `<b>${esc(it.label)}:</b> ${linked}`;
99
+ }
100
+ return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
101
+ };
58
102
  // Длинное пояснение (примечание, детали инцидента) — цитатой: у Telegram это
59
103
  // полоска слева и лёгкий отступ, читается как «подробности», а не как часть
60
104
  // заголовка. Длиннее ~400 знаков — цитата сворачивается сама (`expandable`,
@@ -67,109 +111,141 @@ const note = (text) => {
67
111
  const body = esc(text);
68
112
  return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
69
113
  };
70
- const link = (url, label) => url ? `<a href="${esc(url)}">${esc(label)}</a>` : null;
71
114
  const join = (parts) => parts.filter((p) => p !== null).join('\n');
72
- /** Список позиций общий для `job` и `report`, чтобы они не разъехались. */
73
- const bullets = (items) => (items ?? []).map((it) => (it.url ? `• <a href="${esc(it.url)}">${esc(it.text)}</a>` : `• ${esc(it.text)}`));
115
+ /** Плоский список позиций (без ярлыков) job/report без групп. */
116
+ const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
117
+ /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
118
+ const renderGroup = (g) => [
119
+ group(g.name),
120
+ ...g.items.map((it, i) => groupItem(it, i, false))
121
+ ];
122
+ /**
123
+ * Цитата коммита/задачи: заголовок первой строкой (multiline title режется
124
+ * до первой строки — subject не должен тащить в цитату собственное тело
125
+ * под-коммита), тело — через пустую строку, если есть.
126
+ */
127
+ const commitQuote = (title, body) => {
128
+ if (!title && !body) {
129
+ return null;
130
+ }
131
+ const text = [title ? firstLine(title) : undefined, body].filter(Boolean).join('\n\n');
132
+ return note(text);
133
+ };
134
+ // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
135
+ // закреплённая легенда в форумах обещает это владельцу как факт, не как
136
+ // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
137
+ const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
138
+ /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
139
+ const typeLine = (icon, type, action) => `${icon} ${field(type, action)}`;
74
140
  const renderDeploy = (e) => {
75
- const icon = e.status === 'ok' ? '✅' : '🔴';
76
- const title = e.status === 'ok' ? 'Деплой завершён' : 'Деплой упал';
77
- // Коммит со ссылкой — кликабельная строка вместо голого текста; жалоба
78
- // владельца на некликабельные дайджесты распространяется и сюда.
79
- const commitLine = e.commit
80
- ? e.commitUrl
81
- ? `коммит: <a href="${esc(e.commitUrl)}"><b>${esc(e.commit)}</b></a>`
82
- : kv('коммит', e.commit)
83
- : null;
141
+ const icon = e.status === 'ok' ? ICON.ok : ICON.red;
84
142
  return join([
85
- header(icon, title, e.project),
86
- commitLine,
87
- kv('откуда', e.via),
88
- kv('куда', e.target),
89
- note(e.note),
90
- link(e.url, 'Открыть логи')
143
+ typeLine(icon, 'Deploy', e.status),
144
+ '',
145
+ fieldLink('Commit', e.commitUrl, e.commit),
146
+ commitQuote(e.commitTitle, e.commitBody),
147
+ field('Via', e.via),
148
+ field('Target', e.target),
149
+ field('Reason', e.note),
150
+ e.workflowUrl ? '' : null,
151
+ fieldAction('Workflow', e.workflowUrl, e.workflowName)
91
152
  ]);
92
153
  };
93
154
  const renderJob = (e) => {
94
- const icon = e.status === 'ok' ? '' : '🔴';
95
- const items = bullets(e.items);
155
+ const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
156
+ const hasItems = (e.items ?? []).length > 0;
96
157
  return join([
97
- header(icon, e.job, e.project),
98
- ...(e.stats ?? []).map(([label, value]) => kv(label, value)),
99
- items.length > 0 ? '' : null,
100
- ...items,
101
- note(e.note),
102
- link(e.url, 'Подробнее')
158
+ typeLine(icon, 'Job', e.status),
159
+ '',
160
+ field('Reason', e.note),
161
+ ...(e.stats ?? []).map(([label, value]) => field(label, value)),
162
+ hasItems ? '' : null,
163
+ hasItems ? group('Disabled workflows') : null,
164
+ ...(hasItems && e.status === 'disabled' ? bullets(e.items, true) : hasItems ? bullets(e.items, false) : []),
165
+ e.workflowUrl ? '' : null,
166
+ fieldAction('Workflow', e.workflowUrl, e.workflowName)
103
167
  ]);
104
168
  };
105
169
  const renderReport = (e) => {
106
- const items = bullets(e.items);
170
+ if (e.groups && e.groups.length > 0) {
171
+ const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
172
+ return join([typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title), '', ...body]);
173
+ }
174
+ const items = bullets(e.items, false);
107
175
  return join([
108
- header('📊', e.title, e.project),
109
- e.period ? esc(e.period) : null,
110
- e.period ? '' : null,
111
- ...e.lines.map(([label, value]) => kv(label, value)),
176
+ typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
177
+ '',
178
+ ...(e.lines ?? []).map(([label, value]) => field(label, value)),
112
179
  items.length > 0 ? '' : null,
113
- ...items,
114
- link(e.url, 'Открыть отчёт')
180
+ ...items
115
181
  ]);
116
182
  };
117
183
  const renderCi = (e) => {
118
- const icon = e.status === 'ok' ? '✅' : '🔴';
119
- const title = e.status === 'ok' ? 'CI зелёный' : 'CI упал';
184
+ const icon = e.status === 'ok' ? ICON.ok : ICON.red;
120
185
  return join([
121
- header(icon, title, e.project),
122
- kv('ветка', e.branch),
123
- kv('коммит', e.commit),
124
- kv('автор', e.actor),
125
- link(e.url, 'Открыть логи')
186
+ typeLine(icon, 'CI', e.status),
187
+ '',
188
+ fieldLink('Commit', e.commitUrl, e.commit),
189
+ commitQuote(e.commitTitle, e.commitBody),
190
+ field('Actor', e.actor),
191
+ e.workflowUrl ? '' : null,
192
+ fieldAction('Workflow', e.workflowUrl, e.workflowName)
126
193
  ]);
127
194
  };
128
- // Значок у каждого вида свой: в ленте Ops событие узнаётся по нему до чтения
129
- // текста. Дублировать значок между видами нельзя — легенда закреплена в теме
130
- // и обещает однозначность.
131
- const PR_TITLES = {
132
- opened: { icon: '🔀', verb: 'открыт' },
133
- ready_for_review: { icon: '📤', verb: 'готов к ревью' },
134
- review_requested: { icon: '👁', verb: 'ждёт ревью' },
135
- approved: { icon: '👍', verb: 'ревью пройдено' },
136
- changes_requested: { icon: '📝', verb: 'запрошены правки' },
137
- merged: { icon: '✅', verb: 'смёржен' },
138
- closed: { icon: '⛔', verb: 'закрыт без слияния' }
139
- };
140
- const ISSUE_TITLES = {
141
- opened: { icon: '🆕', verb: 'заведена' },
142
- assigned: { icon: '🙋', verb: 'взята в работу' },
143
- closed: { icon: '☑️', verb: 'закрыта' }
144
- };
145
- const renderPr = (e) => {
146
- const { icon, verb } = PR_TITLES[e.action];
147
- return join([
148
- header(icon, `PR #${e.number} ${verb}`, e.project),
149
- esc(e.title),
150
- kv('автор', e.author),
151
- kv('ревьюер', e.reviewer),
152
- link(e.url, 'Открыть PR')
153
- ]);
195
+ // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию
196
+ // `merged`/`approved` = успех, `changes_requested` = требует внимания,
197
+ // остальное = к сведению. Слово действия само по себе уже говорит, что
198
+ // произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
199
+ const PR_ICON = {
200
+ opened: ICON.info,
201
+ ready_for_review: ICON.info,
202
+ review_requested: ICON.info,
203
+ approved: ICON.ok,
204
+ changes_requested: ICON.red,
205
+ merged: ICON.ok,
206
+ closed: ICON.info
154
207
  };
155
- const renderIssue = (e) => {
156
- const { icon, verb } = ISSUE_TITLES[e.action];
157
- return join([
158
- header(icon, `Задача #${e.number} ${verb}`, e.project),
159
- esc(e.title),
160
- kv('автор', e.author),
161
- kv('исполнитель', e.assignee),
162
- link(e.url, 'Открыть задачу')
163
- ]);
208
+ const ISSUE_ICON = {
209
+ opened: ICON.info,
210
+ assigned: ICON.info,
211
+ closed: ICON.ok
164
212
  };
165
- const renderIncident = (e) => join([header('🚨', 'Инцидент', e.project), esc(e.title), note(e.detail), link(e.url, 'Подробнее')]);
166
- const renderHeartbeatMiss = (e) => join([
167
- header('🔴', `Не отметилась: ${e.job}`, e.project),
168
- kv('последний раз', e.lastSeen),
169
- kv('ожидалось', e.expected)
213
+ const renderPr = (e) => join([
214
+ typeLine(PR_ICON[e.action], 'PR', e.action),
215
+ '',
216
+ fieldLink('Pr', e.url, `#${e.number}`),
217
+ note(e.title),
218
+ '',
219
+ field('Author', e.author),
220
+ field('Reviewer', e.reviewer)
170
221
  ]);
222
+ const renderIssue = (e) => join([
223
+ typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
224
+ '',
225
+ fieldLink('Issue', e.url, `#${e.number}`),
226
+ commitQuote(e.title, e.body),
227
+ field('Author', e.author),
228
+ field('Assignee', e.assignee)
229
+ ]);
230
+ const renderIncident = (e) => join([
231
+ typeLine(ICON.alarm, 'Incident', 'open'),
232
+ '',
233
+ field('Reason', e.detail ?? e.title),
234
+ e.logs || e.url ? '' : null,
235
+ fieldCode('Logs', e.logs),
236
+ fieldAction('Workflow', e.url, undefined)
237
+ ]);
238
+ const renderHeartbeatMiss = (e) => {
239
+ const icon = e.recovered ? ICON.ok : ICON.red;
240
+ const action = e.recovered ? 'ok' : 'miss';
241
+ const reason = e.note ??
242
+ (e.recovered
243
+ ? `${e.job} is reporting again${e.lastSeen ? ` — last run ${e.lastSeen}` : ''}`
244
+ : `${e.job} — no reports${e.expected ? ` — expected ${e.expected}` : ''}${e.lastSeen ? `, last seen ${e.lastSeen}` : ''}`);
245
+ return join([typeLine(icon, 'Heartbeat', action), '', field('Reason', reason)]);
246
+ };
171
247
  // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
172
- const renderFile = (e) => join([header('📄', e.title, e.project), note(e.note)]);
248
+ const renderFile = (e) => join([typeLine(ICON.info, 'File', 'new'), '', field('Reason', e.note ?? e.title)]);
173
249
  const RENDERERS = {
174
250
  deploy: renderDeploy,
175
251
  job: renderJob,
@@ -181,28 +257,47 @@ const RENDERERS = {
181
257
  heartbeat_miss: renderHeartbeatMiss,
182
258
  file: renderFile
183
259
  };
184
- /**
185
- * Ключ задачи последняя строка карточки: `#ключ` курсивом в <code>. Без
186
- * названия проекта: `targets()` никогда не шлёт карточку в чужой форум,
187
- * проект и так на виду в заголовке («· mac-config»), а дублирующий префикс
188
- * только растягивал тег на лишнюю строку в узком экране телефона (жалоба
189
- * владельца 18.08). Явный `key` побеждает; выведенный строится из заголовка
190
- * и наследует хрупкость формулировки регулярные отправители передают явный.
191
- * Ключ переживает MTProto-чтение (простой текст, не разметка), по нему
192
- * разборщик сверяет 🔴 с более поздней успешной карточкой той же задачи —
193
- * внутри чата одного проекта, где ключ и так уникален.
194
- */
260
+ // Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
261
+ // (решение владельца 20.08.2026): раньше это были два разных представления
262
+ // одного факта (снизу дефисный `#ci-arvent`, сверху теги вручную), и это
263
+ // читалось как дублирование. Разделитель подчёркивание, не дефис: дефис
264
+ // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
265
+ // только как `#mac`), а тег ДОЛЖЕН быть кликабельным это и есть фильтр
266
+ // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
195
267
  const slug = (raw) => raw
196
268
  .toLowerCase()
197
- .replace(/[^\p{L}\p{N}]+/gu, '-')
198
- .replace(/^-+|-+$/g, '')
199
- // Ключ — идентификатор, не пересказ: без среза тег из длинного заголовка
200
- // съедал бюджет caption до отрицательного, и slice с минусом возвращал
201
- // почти весь текст — Telegram отвечал постоянным 400, файл терялся.
269
+ .replace(/[^\p{L}\p{N}]+/gu, '_')
270
+ .replace(/^_+|_+$/g, '')
202
271
  .slice(0, 60);
272
+ // Тип-тег наверху — не буквальный `e.type`: `heartbeat_miss` читался бы как
273
+ // `#heartbeat_miss`, а видимый тип у владельца всегда просто `#heartbeat`
274
+ // (зелёная и красная карточки одного вида — один и тот же тип-тег).
275
+ const TYPE_TAG = {
276
+ deploy: 'deploy',
277
+ job: 'job',
278
+ report: 'report',
279
+ ci: 'ci',
280
+ pr: 'pr',
281
+ issue: 'issue',
282
+ incident: 'incident',
283
+ heartbeat_miss: 'heartbeat',
284
+ file: 'file'
285
+ };
286
+ /**
287
+ * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
288
+ * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
289
+ * карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
290
+ * выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
291
+ * потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
292
+ * заголовок и есть единственное стабильное поле).
293
+ */
203
294
  export const eventKey = (e) => {
204
295
  const fallback = () => {
205
296
  switch (e.type) {
297
+ case 'ci':
298
+ return slug(e.branch || e.project);
299
+ case 'deploy':
300
+ return slug(e.target || e.project);
206
301
  case 'job':
207
302
  case 'heartbeat_miss':
208
303
  return slug(e.job);
@@ -211,21 +306,20 @@ export const eventKey = (e) => {
211
306
  case 'file':
212
307
  return slug(e.title);
213
308
  case 'pr':
214
- return `pr-${e.number}`;
309
+ return `p${e.number}`;
215
310
  case 'issue':
216
- return `issue-${e.number}`;
217
- default:
218
- return e.type;
311
+ return `i${e.number}`;
219
312
  }
220
313
  };
221
314
  return e.key ? slug(e.key) : fallback();
222
315
  };
223
- const keyLine = (e) => `<i><code>#${esc(eventKey(e))}</code></i>`;
316
+ const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
224
317
  /**
225
318
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
226
- * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
227
- * карточка без ключа была бы невидима разборщикуровно на самых длинных,
228
- * то есть самых важных сообщениях.
319
+ * Теги ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
320
+ * несут и человеческий фильтр, и машинный ключ разборщика обрезанная
321
+ * карточка без них была бы не только некликабельной, но и невидимой
322
+ * разборщику ровно на самых длинных, то есть самых важных сообщениях.
229
323
  */
230
324
  export const render = (e) => {
231
325
  const renderer = RENDERERS[e.type];
@@ -235,10 +329,10 @@ export const render = (e) => {
235
329
  if (typeof renderer !== 'function') {
236
330
  throw new Error(`неизвестный тип события: ${String(e.type)}`);
237
331
  }
238
- const tag = keyLine(e);
332
+ const tags = tagsLine(e);
239
333
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
240
334
  // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
241
335
  // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
242
- const budget = Math.max(64, e.type === 'file' ? 1024 - tag.length - 40 : 4000 - tag.length - 1);
243
- return `${clampMessage(renderer(e), budget)}\n${tag}`;
336
+ const budget = Math.max(64, e.type === 'file' ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
337
+ return `${tags}\n${clampMessage(renderer(e), budget)}`;
244
338
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",