@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 +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 +208 -114
- 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,54 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
49
51
|
.join('');
|
|
50
52
|
return `${body}${tail}\n…`;
|
|
51
53
|
};
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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
|
-
/**
|
|
73
|
-
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)}`;
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 === '
|
|
95
|
-
const
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
...
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
e.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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([
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
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 `
|
|
309
|
+
return `p${e.number}`;
|
|
215
310
|
case 'issue':
|
|
216
|
-
return `
|
|
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
|
|
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
|
|
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 -
|
|
243
|
-
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)}`;
|
|
244
338
|
};
|
package/package.json
CHANGED