@mikitasazan/notify 1.2.1 → 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 +27 -3
- package/dist/events.d.ts +50 -3
- package/dist/events.js +9 -2
- package/dist/render.d.ts +29 -11
- package/dist/render.js +196 -115
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
*
|
|
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, '&')
|
|
@@ -49,12 +51,6 @@ 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
|
-
//
|
|
58
54
|
// Только первая строка: однострочное поле по контракту (коммит, ветка,
|
|
59
55
|
// автор, статистика), а не место для абзаца. Живой случай (18.08): CI-карточка
|
|
60
56
|
// понесла ПОЛНОЕ тело коммита с историей под-коммитов через `--commit` и вместо
|
|
@@ -67,7 +63,42 @@ const firstLine = (value) => {
|
|
|
67
63
|
}
|
|
68
64
|
return `${value.split('\n')[0]}…`;
|
|
69
65
|
};
|
|
70
|
-
|
|
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
|
+
};
|
|
71
102
|
// Длинное пояснение (примечание, детали инцидента) — цитатой: у Telegram это
|
|
72
103
|
// полоска слева и лёгкий отступ, читается как «подробности», а не как часть
|
|
73
104
|
// заголовка. Длиннее ~400 знаков — цитата сворачивается сама (`expandable`,
|
|
@@ -80,109 +111,141 @@ const note = (text) => {
|
|
|
80
111
|
const body = esc(text);
|
|
81
112
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
82
113
|
};
|
|
83
|
-
const link = (url, label) => url ? `<a href="${esc(url)}">${esc(label)}</a>` : null;
|
|
84
114
|
const join = (parts) => parts.filter((p) => p !== null).join('\n');
|
|
85
|
-
/**
|
|
86
|
-
const bullets = (items) => (items ?? []).map((it) => (it
|
|
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)}`;
|
|
87
140
|
const renderDeploy = (e) => {
|
|
88
|
-
const icon = e.status === 'ok' ?
|
|
89
|
-
const title = e.status === 'ok' ? 'Деплой завершён' : 'Деплой упал';
|
|
90
|
-
// Коммит со ссылкой — кликабельная строка вместо голого текста; жалоба
|
|
91
|
-
// владельца на некликабельные дайджесты распространяется и сюда.
|
|
92
|
-
const commitLine = e.commit
|
|
93
|
-
? e.commitUrl
|
|
94
|
-
? `коммит: <a href="${esc(e.commitUrl)}"><b>${esc(firstLine(e.commit))}</b></a>`
|
|
95
|
-
: kv('коммит', e.commit)
|
|
96
|
-
: null;
|
|
141
|
+
const icon = e.status === 'ok' ? ICON.ok : ICON.red;
|
|
97
142
|
return join([
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
104
152
|
]);
|
|
105
153
|
};
|
|
106
154
|
const renderJob = (e) => {
|
|
107
|
-
const icon = e.status === '
|
|
108
|
-
const
|
|
155
|
+
const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
|
|
156
|
+
const hasItems = (e.items ?? []).length > 0;
|
|
109
157
|
return join([
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
...
|
|
114
|
-
|
|
115
|
-
|
|
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)
|
|
116
167
|
]);
|
|
117
168
|
};
|
|
118
169
|
const renderReport = (e) => {
|
|
119
|
-
|
|
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);
|
|
120
175
|
return join([
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
e.
|
|
124
|
-
...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)),
|
|
125
179
|
items.length > 0 ? '' : null,
|
|
126
|
-
...items
|
|
127
|
-
link(e.url, 'Открыть отчёт')
|
|
180
|
+
...items
|
|
128
181
|
]);
|
|
129
182
|
};
|
|
130
183
|
const renderCi = (e) => {
|
|
131
|
-
const icon = e.status === 'ok' ?
|
|
132
|
-
const title = e.status === 'ok' ? 'CI зелёный' : 'CI упал';
|
|
184
|
+
const icon = e.status === 'ok' ? ICON.ok : ICON.red;
|
|
133
185
|
return join([
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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)
|
|
139
193
|
]);
|
|
140
194
|
};
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const ISSUE_TITLES = {
|
|
154
|
-
opened: { icon: '🆕', verb: 'заведена' },
|
|
155
|
-
assigned: { icon: '🙋', verb: 'взята в работу' },
|
|
156
|
-
closed: { icon: '☑️', verb: 'закрыта' }
|
|
157
|
-
};
|
|
158
|
-
const renderPr = (e) => {
|
|
159
|
-
const { icon, verb } = PR_TITLES[e.action];
|
|
160
|
-
return join([
|
|
161
|
-
header(icon, `PR #${e.number} ${verb}`, e.project),
|
|
162
|
-
esc(e.title),
|
|
163
|
-
kv('автор', e.author),
|
|
164
|
-
kv('ревьюер', e.reviewer),
|
|
165
|
-
link(e.url, 'Открыть PR')
|
|
166
|
-
]);
|
|
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
|
|
167
207
|
};
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
esc(e.title),
|
|
173
|
-
kv('автор', e.author),
|
|
174
|
-
kv('исполнитель', e.assignee),
|
|
175
|
-
link(e.url, 'Открыть задачу')
|
|
176
|
-
]);
|
|
208
|
+
const ISSUE_ICON = {
|
|
209
|
+
opened: ICON.info,
|
|
210
|
+
assigned: ICON.info,
|
|
211
|
+
closed: ICON.ok
|
|
177
212
|
};
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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)
|
|
183
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
|
+
};
|
|
184
247
|
// Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
|
|
185
|
-
const renderFile = (e) => join([
|
|
248
|
+
const renderFile = (e) => join([typeLine(ICON.info, 'File', 'new'), '', field('Reason', e.note ?? e.title)]);
|
|
186
249
|
const RENDERERS = {
|
|
187
250
|
deploy: renderDeploy,
|
|
188
251
|
job: renderJob,
|
|
@@ -194,28 +257,47 @@ const RENDERERS = {
|
|
|
194
257
|
heartbeat_miss: renderHeartbeatMiss,
|
|
195
258
|
file: renderFile
|
|
196
259
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
* Ключ переживает MTProto-чтение (простой текст, не разметка), по нему
|
|
205
|
-
* разборщик сверяет 🔴 с более поздней успешной карточкой той же задачи —
|
|
206
|
-
* внутри чата одного проекта, где ключ и так уникален.
|
|
207
|
-
*/
|
|
260
|
+
// Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
|
|
261
|
+
// (решение владельца 20.08.2026): раньше это были два разных представления
|
|
262
|
+
// одного факта (снизу — дефисный `#ci-arvent`, сверху — теги вручную), и это
|
|
263
|
+
// читалось как дублирование. Разделитель — подчёркивание, не дефис: дефис
|
|
264
|
+
// разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
|
|
265
|
+
// только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
|
|
266
|
+
// «показать всю историю этого экземпляра», которым владелец пользуется вживую.
|
|
208
267
|
const slug = (raw) => raw
|
|
209
268
|
.toLowerCase()
|
|
210
|
-
.replace(/[^\p{L}\p{N}]+/gu, '
|
|
211
|
-
.replace(
|
|
212
|
-
// Ключ — идентификатор, не пересказ: без среза тег из длинного заголовка
|
|
213
|
-
// съедал бюджет caption до отрицательного, и slice с минусом возвращал
|
|
214
|
-
// почти весь текст — Telegram отвечал постоянным 400, файл терялся.
|
|
269
|
+
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
270
|
+
.replace(/^_+|_+$/g, '')
|
|
215
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
|
+
*/
|
|
216
294
|
export const eventKey = (e) => {
|
|
217
295
|
const fallback = () => {
|
|
218
296
|
switch (e.type) {
|
|
297
|
+
case 'ci':
|
|
298
|
+
return slug(e.branch || e.project);
|
|
299
|
+
case 'deploy':
|
|
300
|
+
return slug(e.target || e.project);
|
|
219
301
|
case 'job':
|
|
220
302
|
case 'heartbeat_miss':
|
|
221
303
|
return slug(e.job);
|
|
@@ -224,21 +306,20 @@ export const eventKey = (e) => {
|
|
|
224
306
|
case 'file':
|
|
225
307
|
return slug(e.title);
|
|
226
308
|
case 'pr':
|
|
227
|
-
return `
|
|
309
|
+
return `p${e.number}`;
|
|
228
310
|
case 'issue':
|
|
229
|
-
return `
|
|
230
|
-
default:
|
|
231
|
-
return e.type;
|
|
311
|
+
return `i${e.number}`;
|
|
232
312
|
}
|
|
233
313
|
};
|
|
234
314
|
return e.key ? slug(e.key) : fallback();
|
|
235
315
|
};
|
|
236
|
-
const
|
|
316
|
+
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
|
|
237
317
|
/**
|
|
238
318
|
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
319
|
+
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
|
320
|
+
* несут и человеческий фильтр, и машинный ключ разборщика — обрезанная
|
|
321
|
+
* карточка без них была бы не только некликабельной, но и невидимой
|
|
322
|
+
* разборщику ровно на самых длинных, то есть самых важных сообщениях.
|
|
242
323
|
*/
|
|
243
324
|
export const render = (e) => {
|
|
244
325
|
const renderer = RENDERERS[e.type];
|
|
@@ -248,10 +329,10 @@ export const render = (e) => {
|
|
|
248
329
|
if (typeof renderer !== 'function') {
|
|
249
330
|
throw new Error(`неизвестный тип события: ${String(e.type)}`);
|
|
250
331
|
}
|
|
251
|
-
const
|
|
332
|
+
const tags = tagsLine(e);
|
|
252
333
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
|
253
334
|
// многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
|
|
254
335
|
// есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
|
|
255
|
-
const budget = Math.max(64, e.type === 'file' ? 1024 -
|
|
256
|
-
return `${clampMessage(renderer(e), budget)}
|
|
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)}`;
|
|
257
338
|
};
|
package/package.json
CHANGED