@mikitasazan/notify 1.2.1 → 1.4.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-flags.d.ts +11 -0
- package/dist/cli-flags.js +19 -0
- package/dist/cli.js +70 -11
- package/dist/events.d.ts +62 -3
- package/dist/events.js +9 -2
- package/dist/render.d.ts +29 -11
- package/dist/render.js +276 -113
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
|
|
3
|
+
* опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
|
|
4
|
+
* `--note=...` рисовал карточку без причины и выходил нулём — владелец получал
|
|
5
|
+
* обрезанное сообщение, а вызывающая задача считала, что всё хорошо.
|
|
6
|
+
*
|
|
7
|
+
* Список закрытый и проверяется тестом: тест вытаскивает из этого же файла все
|
|
8
|
+
* имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
|
|
9
|
+
* здесь. Разойтись молча он не может.
|
|
10
|
+
*/
|
|
11
|
+
export declare const KNOWN_FLAGS: ReadonlySet<string>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
|
|
3
|
+
* опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
|
|
4
|
+
* `--note=...` рисовал карточку без причины и выходил нулём — владелец получал
|
|
5
|
+
* обрезанное сообщение, а вызывающая задача считала, что всё хорошо.
|
|
6
|
+
*
|
|
7
|
+
* Список закрытый и проверяется тестом: тест вытаскивает из этого же файла все
|
|
8
|
+
* имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
|
|
9
|
+
* здесь. Разойтись молча он не может.
|
|
10
|
+
*/
|
|
11
|
+
export const KNOWN_FLAGS = new Set([
|
|
12
|
+
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
|
+
'commit-body', 'commit-title', 'commit-url', 'detail', 'expected',
|
|
14
|
+
'filename', 'item', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
15
|
+
'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
16
|
+
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
17
|
+
// Флаги без значения. Живут здесь же, чтобы разбор и список не разошлись.
|
|
18
|
+
'json', 'recovered', 'dry-run'
|
|
19
|
+
]);
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* notify setup <chat_id форума> <ключ-проекта> # создать вкладки, см. setup.ts
|
|
22
22
|
*/
|
|
23
23
|
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { KNOWN_FLAGS } from "./cli-flags.js";
|
|
25
|
+
import { render } from "./render.js";
|
|
24
26
|
import { notify } from "./send.js";
|
|
25
27
|
import { setupTopic } from "./setup.js";
|
|
26
28
|
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
@@ -39,11 +41,11 @@ if (command === 'setup') {
|
|
|
39
41
|
const flags = new Map();
|
|
40
42
|
const parseErrors = [];
|
|
41
43
|
/** Флаги без значения. Всё остальное обязано его иметь. */
|
|
42
|
-
const BOOLEAN_FLAGS = new Set(['json']);
|
|
44
|
+
const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
|
|
43
45
|
for (let i = 1; i < args.length; i++) {
|
|
44
46
|
const arg = args[i];
|
|
45
47
|
if (!arg.startsWith('--')) {
|
|
46
|
-
parseErrors.push(
|
|
48
|
+
parseErrors.push(`stray argument with no flag: "${arg}"`);
|
|
47
49
|
continue;
|
|
48
50
|
}
|
|
49
51
|
// Форма `--key=value` обязательна для значений, начинающихся с `--`
|
|
@@ -65,13 +67,18 @@ for (let i = 1; i < args.length; i++) {
|
|
|
65
67
|
// ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
|
|
66
68
|
// успешном деплое. Теперь это явная ошибка разбора.
|
|
67
69
|
if (next === undefined || next.startsWith('--')) {
|
|
68
|
-
parseErrors.push(
|
|
70
|
+
parseErrors.push(`flag --${key} with no value`);
|
|
69
71
|
continue;
|
|
70
72
|
}
|
|
71
73
|
i++;
|
|
72
74
|
flags.set(key, [...(flags.get(key) ?? []), next]);
|
|
73
75
|
}
|
|
74
76
|
const one = (key) => flags.get(key)?.[0];
|
|
77
|
+
for (const key of flags.keys()) {
|
|
78
|
+
if (!KNOWN_FLAGS.has(key)) {
|
|
79
|
+
parseErrors.push(`unknown flag --${key}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
75
82
|
// Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
|
|
76
83
|
const num = (key) => {
|
|
77
84
|
const raw = one(key);
|
|
@@ -114,7 +121,7 @@ const ISSUE_ALIASES = {
|
|
|
114
121
|
const prAction = (raw) => {
|
|
115
122
|
const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
|
|
116
123
|
if (!hit) {
|
|
117
|
-
parseErrors.push(`--action:
|
|
124
|
+
parseErrors.push(`--action: unknown PR action "${raw ?? ''}" (${Object.keys(PR_ALIASES).join(', ')})`);
|
|
118
125
|
return 'opened';
|
|
119
126
|
}
|
|
120
127
|
return hit;
|
|
@@ -122,7 +129,7 @@ const prAction = (raw) => {
|
|
|
122
129
|
const issueAction = (raw) => {
|
|
123
130
|
const hit = ISSUE_ALIASES[(raw ?? '').toLowerCase()];
|
|
124
131
|
if (!hit) {
|
|
125
|
-
parseErrors.push(`--action:
|
|
132
|
+
parseErrors.push(`--action: unknown issue action "${raw ?? ''}" (${Object.keys(ISSUE_ALIASES).join(', ')})`);
|
|
126
133
|
return 'opened';
|
|
127
134
|
}
|
|
128
135
|
return hit;
|
|
@@ -139,6 +146,15 @@ const status = () => {
|
|
|
139
146
|
const raw = (one('status') ?? '').toLowerCase();
|
|
140
147
|
return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
|
|
141
148
|
};
|
|
149
|
+
// `job` — единственный тип с третьим состоянием (`disabled`): задача не
|
|
150
|
+
// провалилась сама, её выключил кто-то извне (GitHub Actions без минут).
|
|
151
|
+
const jobStatus = () => {
|
|
152
|
+
const raw = (one('status') ?? '').toLowerCase();
|
|
153
|
+
if (raw === 'disabled') {
|
|
154
|
+
return 'disabled';
|
|
155
|
+
}
|
|
156
|
+
return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
|
|
157
|
+
};
|
|
142
158
|
let event;
|
|
143
159
|
if (flags.has('json')) {
|
|
144
160
|
try {
|
|
@@ -148,7 +164,9 @@ if (flags.has('json')) {
|
|
|
148
164
|
event = { ...payload, type: command };
|
|
149
165
|
}
|
|
150
166
|
catch (err) {
|
|
151
|
-
|
|
167
|
+
// Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
|
|
168
|
+
// разбор JSON означал бы зелёный крон без дневного отчёта.
|
|
169
|
+
parseErrors.push(`--json from stdin did not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
else {
|
|
@@ -160,6 +178,10 @@ else {
|
|
|
160
178
|
status: status(),
|
|
161
179
|
commit: one('commit'),
|
|
162
180
|
commitUrl: one('commit-url'),
|
|
181
|
+
commitTitle: one('commit-title'),
|
|
182
|
+
commitBody: one('commit-body'),
|
|
183
|
+
workflowUrl: one('workflow-url'),
|
|
184
|
+
workflowName: one('workflow-name'),
|
|
163
185
|
url: one('url'),
|
|
164
186
|
target: one('target'),
|
|
165
187
|
via: one('via'),
|
|
@@ -171,10 +193,12 @@ else {
|
|
|
171
193
|
type: 'job',
|
|
172
194
|
project: project(),
|
|
173
195
|
job: one('job') ?? '(без имени)',
|
|
174
|
-
status:
|
|
196
|
+
status: jobStatus(),
|
|
175
197
|
stats: pairs('stat'),
|
|
176
198
|
items: items(),
|
|
177
199
|
note: one('note'),
|
|
200
|
+
workflowUrl: one('workflow-url'),
|
|
201
|
+
workflowName: one('workflow-name'),
|
|
178
202
|
url: one('url')
|
|
179
203
|
};
|
|
180
204
|
break;
|
|
@@ -196,7 +220,13 @@ else {
|
|
|
196
220
|
status: status(),
|
|
197
221
|
branch: one('branch'),
|
|
198
222
|
commit: one('commit'),
|
|
223
|
+
commitUrl: one('commit-url'),
|
|
224
|
+
commitTitle: one('commit-title'),
|
|
225
|
+
commitBody: one('commit-body'),
|
|
199
226
|
actor: one('actor'),
|
|
227
|
+
note: one('note'),
|
|
228
|
+
workflowUrl: one('workflow-url'),
|
|
229
|
+
workflowName: one('workflow-name'),
|
|
200
230
|
url: one('url')
|
|
201
231
|
};
|
|
202
232
|
break;
|
|
@@ -207,6 +237,7 @@ else {
|
|
|
207
237
|
action: prAction(one('action')),
|
|
208
238
|
number: num('number'),
|
|
209
239
|
title: one('title') ?? '(без заголовка)',
|
|
240
|
+
body: one('body'),
|
|
210
241
|
author: one('author'),
|
|
211
242
|
reviewer: one('reviewer'),
|
|
212
243
|
url: one('url')
|
|
@@ -219,6 +250,7 @@ else {
|
|
|
219
250
|
action: issueAction(one('action')),
|
|
220
251
|
number: num('number'),
|
|
221
252
|
title: one('title') ?? '(без заголовка)',
|
|
253
|
+
body: one('body'),
|
|
222
254
|
author: one('author'),
|
|
223
255
|
assignee: one('assignee'),
|
|
224
256
|
url: one('url')
|
|
@@ -230,6 +262,7 @@ else {
|
|
|
230
262
|
project: project(),
|
|
231
263
|
title: one('title') ?? '(без заголовка)',
|
|
232
264
|
detail: one('detail'),
|
|
265
|
+
logs: one('logs'),
|
|
233
266
|
url: one('url')
|
|
234
267
|
};
|
|
235
268
|
break;
|
|
@@ -239,7 +272,9 @@ else {
|
|
|
239
272
|
project: project(),
|
|
240
273
|
job: one('job') ?? '(без имени)',
|
|
241
274
|
lastSeen: one('last-seen'),
|
|
242
|
-
expected: one('expected')
|
|
275
|
+
expected: one('expected'),
|
|
276
|
+
recovered: flags.has('recovered'),
|
|
277
|
+
note: one('note')
|
|
243
278
|
};
|
|
244
279
|
break;
|
|
245
280
|
case 'file': {
|
|
@@ -258,7 +293,10 @@ else {
|
|
|
258
293
|
break;
|
|
259
294
|
}
|
|
260
295
|
default:
|
|
261
|
-
|
|
296
|
+
// В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
|
|
297
|
+
// тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
|
|
298
|
+
// нулём, ничего не отправив и ничего об этом не сказав.
|
|
299
|
+
parseErrors.push(`unknown event type: ${command ?? '(none given)'}`);
|
|
262
300
|
}
|
|
263
301
|
}
|
|
264
302
|
// --key применим к любому типу — одна точка вместо строки в каждом case
|
|
@@ -273,7 +311,26 @@ if (parseErrors.length > 0) {
|
|
|
273
311
|
for (const err of parseErrors) {
|
|
274
312
|
log(err);
|
|
275
313
|
}
|
|
276
|
-
|
|
314
|
+
// The word `failed` is a CONTRACT, not prose. Two readers match on it: the
|
|
315
|
+
// GitHub Action turns it into a yellow annotation, and the VPS watchdog reads
|
|
316
|
+
// the combined stream for `sent|failed|skipped`. Before this, a parse error
|
|
317
|
+
// printed neither word — the run stayed green, the watchdog stayed quiet, and
|
|
318
|
+
// the card simply never existed.
|
|
319
|
+
// It must NOT contain the substring `sent`: heartbeat-check.sh matches `*sent*`
|
|
320
|
+
// and would read a failure as a success.
|
|
321
|
+
log('failed: bad command, nothing delivered');
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
if (event && flags.has('dry-run')) {
|
|
325
|
+
// The rendered card on STDOUT, nothing sent and no token needed. This is how
|
|
326
|
+
// a change to the format is shown to the owner before it reaches a forum, and
|
|
327
|
+
// how ~25 edited call sites are checked one by one — the package always exits
|
|
328
|
+
// 0, so a typo in a flag is otherwise silent.
|
|
329
|
+
//
|
|
330
|
+
// stdout, not stderr: every other line this CLI prints goes to stderr, and
|
|
331
|
+
// watchdogs read that stream for the words `sent|failed|skipped`. A card
|
|
332
|
+
// printed there would be read as a verdict.
|
|
333
|
+
process.stdout.write(`${render(event)}\n`);
|
|
277
334
|
process.exit(0);
|
|
278
335
|
}
|
|
279
336
|
if (event) {
|
|
@@ -284,7 +341,9 @@ if (event) {
|
|
|
284
341
|
log(await notify(event));
|
|
285
342
|
}
|
|
286
343
|
catch (err) {
|
|
287
|
-
|
|
344
|
+
// Слово `failed` — контракт, тот же, что у ошибки разбора выше. Без него
|
|
345
|
+
// исключение при отправке читалось сторожами как «ничего не случилось».
|
|
346
|
+
log(`failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
288
347
|
}
|
|
289
348
|
}
|
|
290
349
|
process.exit(0);
|
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,20 @@ 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;
|
|
90
|
+
/**
|
|
91
|
+
* Запасное имя для ссылки на прогон: половина отправителей шлёт её как
|
|
92
|
+
* `--url`. Рендер берёт `workflowUrl ?? url`, так что оба имени работают.
|
|
93
|
+
* В новых вызовах предпочитай `workflowUrl` — оно говорит, куда ведёт.
|
|
94
|
+
*/
|
|
72
95
|
url?: string;
|
|
73
96
|
}
|
|
74
97
|
/** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
|
|
@@ -77,13 +100,24 @@ export type NotifyEvent = Keyed & (
|
|
|
77
100
|
project: Project;
|
|
78
101
|
title: string;
|
|
79
102
|
period?: string;
|
|
80
|
-
|
|
103
|
+
/** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
|
|
104
|
+
lines?: Array<[label: string, value: string | number]>;
|
|
81
105
|
/**
|
|
82
106
|
* Список позиций со ссылками — для дайджестов задач, где ценность в
|
|
83
107
|
* самих названиях, а не в цифре. Рендерятся отдельным блоком после
|
|
84
108
|
* `lines`.
|
|
85
109
|
*/
|
|
86
110
|
items?: Item[];
|
|
111
|
+
/**
|
|
112
|
+
* Именованные группы (доска задач: Ready/In Progress/Not on the
|
|
113
|
+
* board; аналитика: Metrics/Links) — каждая со своим заголовком и
|
|
114
|
+
* списком позиций. Заменяет `lines`/`items`, когда задан: разные
|
|
115
|
+
* отчёты используют либо плоский вид, либо группы, не оба разом.
|
|
116
|
+
*/
|
|
117
|
+
groups?: Array<{
|
|
118
|
+
name: string;
|
|
119
|
+
items: Item[];
|
|
120
|
+
}>;
|
|
87
121
|
url?: string;
|
|
88
122
|
}
|
|
89
123
|
/** Итог CI на основной ветке. */
|
|
@@ -93,7 +127,22 @@ export type NotifyEvent = Keyed & (
|
|
|
93
127
|
status: 'ok' | 'fail';
|
|
94
128
|
branch?: string;
|
|
95
129
|
commit?: string;
|
|
130
|
+
/** Ссылка на коммит — хэш становится кликабельным. */
|
|
131
|
+
commitUrl?: string;
|
|
132
|
+
/** Заголовок коммита (subject) — отдельное поле `Title:`, не цитата. */
|
|
133
|
+
commitTitle?: string;
|
|
134
|
+
/** Тело коммита (после subject) — та же цитата, что и заголовок. */
|
|
135
|
+
commitBody?: string;
|
|
96
136
|
actor?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Why this run happened, when there is no commit to point at: a nightly
|
|
139
|
+
* schedule, a manual press. Renders as `Reason:`, same as on deploy.
|
|
140
|
+
*/
|
|
141
|
+
note?: string;
|
|
142
|
+
/** Ссылка на прогон (workflow run) — отдельно от `url`, который у CI не используется. */
|
|
143
|
+
workflowUrl?: string;
|
|
144
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
|
|
145
|
+
workflowName?: string;
|
|
97
146
|
url?: string;
|
|
98
147
|
}
|
|
99
148
|
/**
|
|
@@ -107,6 +156,8 @@ export type NotifyEvent = Keyed & (
|
|
|
107
156
|
action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
|
|
108
157
|
number: number;
|
|
109
158
|
title: string;
|
|
159
|
+
/** PR description — quoted on its own; the title is the `Title:` field above it. */
|
|
160
|
+
body?: string;
|
|
110
161
|
author?: string;
|
|
111
162
|
reviewer?: string;
|
|
112
163
|
url?: string;
|
|
@@ -118,6 +169,8 @@ export type NotifyEvent = Keyed & (
|
|
|
118
169
|
action: 'opened' | 'assigned' | 'closed';
|
|
119
170
|
number: number;
|
|
120
171
|
title: string;
|
|
172
|
+
/** Тело задачи — цитата под полем `Title:`, отдельно от заголовка. */
|
|
173
|
+
body?: string;
|
|
121
174
|
author?: string;
|
|
122
175
|
assignee?: string;
|
|
123
176
|
url?: string;
|
|
@@ -128,6 +181,8 @@ export type NotifyEvent = Keyed & (
|
|
|
128
181
|
project: Project;
|
|
129
182
|
title: string;
|
|
130
183
|
detail?: string;
|
|
184
|
+
/** Локальный путь к логам (не URL — рендерится моноширинным, для копирования, не для клика). */
|
|
185
|
+
logs?: string;
|
|
131
186
|
url?: string;
|
|
132
187
|
}
|
|
133
188
|
/** Задача не отметилась вовремя — сторож молчания (heartbeat). */
|
|
@@ -137,6 +192,10 @@ export type NotifyEvent = Keyed & (
|
|
|
137
192
|
job: string;
|
|
138
193
|
lastSeen?: string;
|
|
139
194
|
expected?: string;
|
|
195
|
+
/** Задача снова отчиталась — тот же тип, зелёная карточка вместо красной, ключ (для сверки) не меняется. */
|
|
196
|
+
recovered?: boolean;
|
|
197
|
+
/** Готовое предложение-причина; без него собирается из lastSeen/expected. */
|
|
198
|
+
note?: string;
|
|
140
199
|
}
|
|
141
200
|
/**
|
|
142
201
|
* Файл-вложение (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, '&')
|
|
@@ -39,7 +41,12 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
39
41
|
// blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
|
|
40
42
|
// прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
|
|
41
43
|
// цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
|
|
42
|
-
|
|
44
|
+
// `u` в списке с 2026-08-25: заголовок группы рисуется как `<i><u>…</u></i>`,
|
|
45
|
+
// и обрезанный посередине длинный заголовок оставлял `<u>` незакрытым.
|
|
46
|
+
// Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
|
|
47
|
+
// отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
|
|
48
|
+
// отчётом с именем группы в 5000 знаков.
|
|
49
|
+
const tail = ['b', 'a', 'i', 'u', 'code', 'blockquote']
|
|
43
50
|
.filter((t) => {
|
|
44
51
|
const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
|
|
45
52
|
const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
|
|
@@ -49,12 +56,6 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
49
56
|
.join('');
|
|
50
57
|
return `${body}${tail}\n…`;
|
|
51
58
|
};
|
|
52
|
-
const header = (icon, title, project) => `${icon} <b>${esc(title)}</b> · ${esc(project)}`;
|
|
53
|
-
// Тире, не жирное значение: сплошной жирный текст в первой версии карточки
|
|
54
|
-
// читался как крик (жалоба владельца 18.08). Иконка-лид уже держит внимание
|
|
55
|
-
// на заголовке, факты идут построчно и без выделения — глаз сам находит
|
|
56
|
-
// цифру рядом с меткой.
|
|
57
|
-
//
|
|
58
59
|
// Только первая строка: однострочное поле по контракту (коммит, ветка,
|
|
59
60
|
// автор, статистика), а не место для абзаца. Живой случай (18.08): CI-карточка
|
|
60
61
|
// понесла ПОЛНОЕ тело коммита с историей под-коммитов через `--commit` и вместо
|
|
@@ -67,7 +68,51 @@ const firstLine = (value) => {
|
|
|
67
68
|
}
|
|
68
69
|
return `${value.split('\n')[0]}…`;
|
|
69
70
|
};
|
|
70
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Поле: `<b>Ярлык:</b> значение` — жирный ярлык с большой буквы, значение
|
|
73
|
+
* обычным. `null` отбрасывается наравне с `undefined`/`''` — источники поля
|
|
74
|
+
* это JSON со stdin (`--json`) и объекты с сервера, где отсутствующее
|
|
75
|
+
* значение сериализуется как `null`, а не как пропущенный ключ.
|
|
76
|
+
*/
|
|
77
|
+
const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
|
|
78
|
+
/**
|
|
79
|
+
* Поле-идентификатор (`commit:`/`pr:`/`issue:`): значение — ссылка, если
|
|
80
|
+
* она есть, иначе обычный текст того же поля — идентификатор не должен
|
|
81
|
+
* пропадать целиком только потому, что вызывающий не передал url.
|
|
82
|
+
*/
|
|
83
|
+
const fieldLink = (label, url, text) => {
|
|
84
|
+
if (text === undefined || text === null || text === '') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
// `firstLine` here for the same reason `field` has it: the linked case used to
|
|
88
|
+
// skip it, so a multi-line value (arvent's two-line `commit`) became two-line
|
|
89
|
+
// LINK TEXT instead of one identifier.
|
|
90
|
+
return url
|
|
91
|
+
? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
|
|
92
|
+
: field(label, text);
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
|
|
96
|
+
* поле — прогону просто некуда вести, показывать голое слово «run» без
|
|
97
|
+
* ссылки бессмысленнее, чем не показывать строку вовсе.
|
|
98
|
+
*/
|
|
99
|
+
// Текст ссылки — имя того, куда она ведёт (имя workflow, имя прогона). Запасное
|
|
100
|
+
// слово было «run»: существительное, которое ничего не называет — владелец читал
|
|
101
|
+
// «Workflow: run» и не понимал, что это. «open» — глагол, он хотя бы честно
|
|
102
|
+
// говорит, что это ссылка, а не название.
|
|
103
|
+
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
104
|
+
/** Моноширинное поле — путь/команда для копирования, не ссылка. */
|
|
105
|
+
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
106
|
+
/** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
|
|
107
|
+
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
108
|
+
/** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
|
|
109
|
+
const groupItem = (it, index, numbered) => {
|
|
110
|
+
const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
|
|
111
|
+
if (it.label) {
|
|
112
|
+
return `<b>${esc(it.label)}:</b> ${linked}`;
|
|
113
|
+
}
|
|
114
|
+
return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
|
|
115
|
+
};
|
|
71
116
|
// Длинное пояснение (примечание, детали инцидента) — цитатой: у Telegram это
|
|
72
117
|
// полоска слева и лёгкий отступ, читается как «подробности», а не как часть
|
|
73
118
|
// заголовка. Длиннее ~400 знаков — цитата сворачивается сама (`expandable`,
|
|
@@ -80,109 +125,209 @@ const note = (text) => {
|
|
|
80
125
|
const body = esc(text);
|
|
81
126
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
82
127
|
};
|
|
83
|
-
const link = (url, label) => url ? `<a href="${esc(url)}">${esc(label)}</a>` : null;
|
|
84
128
|
const join = (parts) => parts.filter((p) => p !== null).join('\n');
|
|
85
|
-
/**
|
|
86
|
-
const bullets = (items) => (items ?? []).map((it) => (it
|
|
129
|
+
/** Плоский список позиций (без ярлыков) — job/report без групп. */
|
|
130
|
+
const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
|
|
131
|
+
/** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
|
|
132
|
+
const renderGroup = (g) => [
|
|
133
|
+
group(g.name),
|
|
134
|
+
...g.items.map((it, i) => groupItem(it, i, false))
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок —
|
|
138
|
+
* обычное поле `Title:`, тело — цитата, и в цитате больше ничего нет.
|
|
139
|
+
*
|
|
140
|
+
* Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
|
|
141
|
+
* строкой. Владелец нашёл, чем это плохо: заголовок — главное в карточке, то,
|
|
142
|
+
* ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
|
|
143
|
+
* одно от другого можно было только по пустой строке. У PR без тела карточка
|
|
144
|
+
* вырождалась в одинокую серую цитату из одной строки.
|
|
145
|
+
*
|
|
146
|
+
* Заголовок режется до первой строки: многострочный subject коммита не должен
|
|
147
|
+
* затягивать в поле собственное тело.
|
|
148
|
+
*/
|
|
149
|
+
const titleField = (title) => field('Title', title);
|
|
150
|
+
const bodyQuote = (body) => body ? note(body) : null;
|
|
151
|
+
// Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
|
|
152
|
+
// закреплённая легенда в форумах обещает это владельцу как факт, не как
|
|
153
|
+
// приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
|
|
154
|
+
const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
|
|
155
|
+
/** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
|
|
156
|
+
// `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
|
|
157
|
+
// JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
|
|
158
|
+
// прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
|
|
159
|
+
const typeLine = (icon, type, action) => {
|
|
160
|
+
// `field` возвращает null на пустом значении, а интерполяция null в шаблон
|
|
161
|
+
// печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
|
|
162
|
+
// достижимо через `--json` и прямой вызов на JS, где типов нет.
|
|
163
|
+
const line = field(type, action);
|
|
164
|
+
return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
|
|
165
|
+
};
|
|
166
|
+
// `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
|
|
167
|
+
// `--url` — это имя было в пакете раньше и осталось в вызовах. Рендер читал
|
|
168
|
+
// только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
|
|
169
|
+
// на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
|
|
170
|
+
// однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
|
|
87
171
|
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;
|
|
172
|
+
const icon = e.status === 'ok' ? ICON.ok : ICON.red;
|
|
97
173
|
return join([
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
174
|
+
typeLine(icon, 'Deploy', e.status),
|
|
175
|
+
'',
|
|
176
|
+
fieldLink('Commit', e.commitUrl, e.commit),
|
|
177
|
+
titleField(e.commitTitle),
|
|
178
|
+
bodyQuote(e.commitBody),
|
|
179
|
+
field('Via', e.via),
|
|
180
|
+
field('Target', e.target),
|
|
181
|
+
field('Reason', e.note),
|
|
182
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
183
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
104
184
|
]);
|
|
105
185
|
};
|
|
106
186
|
const renderJob = (e) => {
|
|
107
|
-
const icon = e.status === '
|
|
108
|
-
const
|
|
187
|
+
const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
|
|
188
|
+
const hasItems = (e.items ?? []).length > 0;
|
|
189
|
+
const disabledList = hasItems && e.status === 'disabled';
|
|
109
190
|
return join([
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
191
|
+
typeLine(icon, 'Job', e.status),
|
|
192
|
+
'',
|
|
193
|
+
// The name is NOT the type line: line 2 is `Job: fail` by the format's own
|
|
194
|
+
// rule, so the name is its own field. Called `Task:` and not `Job:` because
|
|
195
|
+
// repeating the label of the line right above it reads as a mistake.
|
|
196
|
+
// Until now the name was dropped entirely — every caller passed it and the
|
|
197
|
+
// owner only ever saw it as the small grey instance tag.
|
|
198
|
+
field('Task', e.job),
|
|
199
|
+
field('Reason', e.note),
|
|
200
|
+
...(e.stats ?? []).map(([label, value]) => field(label, value)),
|
|
201
|
+
hasItems ? '' : null,
|
|
202
|
+
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
203
|
+
// list, so playhub's daily card of newly published games was headed
|
|
204
|
+
// "Disabled workflows".
|
|
205
|
+
disabledList ? group('Disabled workflows') : null,
|
|
206
|
+
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
207
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
208
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
116
209
|
]);
|
|
117
210
|
};
|
|
118
211
|
const renderReport = (e) => {
|
|
119
|
-
|
|
212
|
+
if (e.groups && e.groups.length > 0) {
|
|
213
|
+
const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
|
|
214
|
+
return join([
|
|
215
|
+
typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
|
|
216
|
+
'',
|
|
217
|
+
...body,
|
|
218
|
+
e.url ? '' : null,
|
|
219
|
+
fieldAction('Details', e.url, undefined)
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
const items = bullets(e.items, false);
|
|
120
223
|
return join([
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
e.
|
|
124
|
-
...e.lines.map(([label, value]) => kv(label, value)),
|
|
224
|
+
typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
|
|
225
|
+
'',
|
|
226
|
+
...(e.lines ?? []).map(([label, value]) => field(label, value)),
|
|
125
227
|
items.length > 0 ? '' : null,
|
|
126
228
|
...items,
|
|
127
|
-
|
|
229
|
+
// Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
|
|
230
|
+
// и дневной отчёт приходил без единственного способа посмотреть подробности.
|
|
231
|
+
e.url ? '' : null,
|
|
232
|
+
fieldAction('Details', e.url, undefined)
|
|
128
233
|
]);
|
|
129
234
|
};
|
|
130
235
|
const renderCi = (e) => {
|
|
131
|
-
const icon = e.status === 'ok' ?
|
|
132
|
-
const title = e.status === 'ok' ? 'CI зелёный' : 'CI упал';
|
|
236
|
+
const icon = e.status === 'ok' ? ICON.ok : ICON.red;
|
|
133
237
|
return join([
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
238
|
+
typeLine(icon, 'CI', e.status),
|
|
239
|
+
'',
|
|
240
|
+
fieldLink('Commit', e.commitUrl, e.commit),
|
|
241
|
+
titleField(e.commitTitle),
|
|
242
|
+
bodyQuote(e.commitBody),
|
|
243
|
+
field('Actor', e.actor),
|
|
244
|
+
field('Reason', e.note),
|
|
245
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
246
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
139
247
|
]);
|
|
140
248
|
};
|
|
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: 'закрыта' }
|
|
249
|
+
// PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
|
|
250
|
+
// `merged`/`approved` = успех, `changes_requested` = требует внимания,
|
|
251
|
+
// остальное = к сведению. Слово действия само по себе уже говорит, что
|
|
252
|
+
// произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
|
|
253
|
+
const PR_ICON = {
|
|
254
|
+
opened: ICON.info,
|
|
255
|
+
ready_for_review: ICON.info,
|
|
256
|
+
review_requested: ICON.info,
|
|
257
|
+
approved: ICON.ok,
|
|
258
|
+
changes_requested: ICON.red,
|
|
259
|
+
merged: ICON.ok,
|
|
260
|
+
closed: ICON.info
|
|
157
261
|
};
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
esc(e.title),
|
|
163
|
-
kv('автор', e.author),
|
|
164
|
-
kv('ревьюер', e.reviewer),
|
|
165
|
-
link(e.url, 'Открыть PR')
|
|
166
|
-
]);
|
|
262
|
+
const ISSUE_ICON = {
|
|
263
|
+
opened: ICON.info,
|
|
264
|
+
assigned: ICON.info,
|
|
265
|
+
closed: ICON.ok
|
|
167
266
|
};
|
|
168
|
-
const
|
|
169
|
-
|
|
267
|
+
const renderPr = (e) => join([
|
|
268
|
+
typeLine(PR_ICON[e.action], 'PR', e.action),
|
|
269
|
+
'',
|
|
270
|
+
// Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
|
|
271
|
+
// такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
|
|
272
|
+
fieldLink('Number', e.url, `#${e.number}`),
|
|
273
|
+
titleField(e.title),
|
|
274
|
+
bodyQuote(e.body),
|
|
275
|
+
// Без пустой строки перед автором: у задачи её нет, и одно и то же поле
|
|
276
|
+
// не должно стоять по-разному в двух соседних карточках. Пустая строка в
|
|
277
|
+
// этом формате означает «дальше указатель, куда пойти» — автор не он.
|
|
278
|
+
field('Author', e.author),
|
|
279
|
+
field('Reviewer', e.reviewer)
|
|
280
|
+
]);
|
|
281
|
+
const renderIssue = (e) => join([
|
|
282
|
+
typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
|
|
283
|
+
'',
|
|
284
|
+
fieldLink('Number', e.url, `#${e.number}`),
|
|
285
|
+
titleField(e.title),
|
|
286
|
+
bodyQuote(e.body),
|
|
287
|
+
field('Author', e.author),
|
|
288
|
+
field('Assignee', e.assignee)
|
|
289
|
+
]);
|
|
290
|
+
const renderIncident = (e) => join([
|
|
291
|
+
typeLine(ICON.alarm, 'Incident', 'open'),
|
|
292
|
+
'',
|
|
293
|
+
// `detail` is a diagnosis of several lines (vault greps three of them plus a
|
|
294
|
+
// log path). It used to go through `field`, which keeps only the first line,
|
|
295
|
+
// so every alarm this package ever sent arrived gutted. Same shape as a
|
|
296
|
+
// commit now: short label, full text quoted under it.
|
|
297
|
+
// Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
|
|
298
|
+
// как у коммита и задачи, и называться в одной карточке он должен так же.
|
|
299
|
+
titleField(e.title),
|
|
300
|
+
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
301
|
+
e.logs || e.url ? '' : null,
|
|
302
|
+
fieldCode('Logs', e.logs),
|
|
303
|
+
fieldAction('Workflow', e.url, undefined)
|
|
304
|
+
]);
|
|
305
|
+
// Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
|
|
306
|
+
// reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
|
|
307
|
+
// свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
|
|
308
|
+
// формат. Отдельного формата больше нет.
|
|
309
|
+
const renderHeartbeatMiss = (e) => {
|
|
310
|
+
const icon = e.recovered ? ICON.ok : ICON.red;
|
|
311
|
+
const action = e.recovered ? 'ok' : 'miss';
|
|
170
312
|
return join([
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
313
|
+
typeLine(icon, 'Heartbeat', action),
|
|
314
|
+
'',
|
|
315
|
+
field('Task', e.job),
|
|
316
|
+
field('Reason', e.note),
|
|
317
|
+
field('Expected', e.expected),
|
|
318
|
+
field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
|
|
176
319
|
]);
|
|
177
320
|
};
|
|
178
|
-
const renderIncident = (e) => join([header('🚨', 'Инцидент', e.project), esc(e.title), note(e.detail), link(e.url, 'Подробнее')]);
|
|
179
|
-
const renderHeartbeatMiss = (e) => join([
|
|
180
|
-
header('🔴', `Не отметилась: ${e.job}`, e.project),
|
|
181
|
-
kv('последний раз', e.lastSeen),
|
|
182
|
-
kv('ожидалось', e.expected)
|
|
183
|
-
]);
|
|
184
321
|
// Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
|
|
185
|
-
const renderFile = (e) => join([
|
|
322
|
+
const renderFile = (e) => join([
|
|
323
|
+
typeLine(ICON.info, 'File', 'new'),
|
|
324
|
+
'',
|
|
325
|
+
// Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
|
|
326
|
+
// приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
|
|
327
|
+
// Один ярлык — один смысл: Title это title, Reason это note.
|
|
328
|
+
field('Title', e.title),
|
|
329
|
+
field('Reason', e.note)
|
|
330
|
+
]);
|
|
186
331
|
const RENDERERS = {
|
|
187
332
|
deploy: renderDeploy,
|
|
188
333
|
job: renderJob,
|
|
@@ -194,28 +339,47 @@ const RENDERERS = {
|
|
|
194
339
|
heartbeat_miss: renderHeartbeatMiss,
|
|
195
340
|
file: renderFile
|
|
196
341
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
* Ключ переживает MTProto-чтение (простой текст, не разметка), по нему
|
|
205
|
-
* разборщик сверяет 🔴 с более поздней успешной карточкой той же задачи —
|
|
206
|
-
* внутри чата одного проекта, где ключ и так уникален.
|
|
207
|
-
*/
|
|
342
|
+
// Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
|
|
343
|
+
// (решение владельца 20.08.2026): раньше это были два разных представления
|
|
344
|
+
// одного факта (снизу — дефисный `#ci-arvent`, сверху — теги вручную), и это
|
|
345
|
+
// читалось как дублирование. Разделитель — подчёркивание, не дефис: дефис
|
|
346
|
+
// разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
|
|
347
|
+
// только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
|
|
348
|
+
// «показать всю историю этого экземпляра», которым владелец пользуется вживую.
|
|
208
349
|
const slug = (raw) => raw
|
|
209
350
|
.toLowerCase()
|
|
210
|
-
.replace(/[^\p{L}\p{N}]+/gu, '
|
|
211
|
-
.replace(
|
|
212
|
-
// Ключ — идентификатор, не пересказ: без среза тег из длинного заголовка
|
|
213
|
-
// съедал бюджет caption до отрицательного, и slice с минусом возвращал
|
|
214
|
-
// почти весь текст — Telegram отвечал постоянным 400, файл терялся.
|
|
351
|
+
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
352
|
+
.replace(/^_+|_+$/g, '')
|
|
215
353
|
.slice(0, 60);
|
|
354
|
+
// Тип-тег наверху — не буквальный `e.type`: `heartbeat_miss` читался бы как
|
|
355
|
+
// `#heartbeat_miss`, а видимый тип у владельца всегда просто `#heartbeat`
|
|
356
|
+
// (зелёная и красная карточки одного вида — один и тот же тип-тег).
|
|
357
|
+
const TYPE_TAG = {
|
|
358
|
+
deploy: 'deploy',
|
|
359
|
+
job: 'job',
|
|
360
|
+
report: 'report',
|
|
361
|
+
ci: 'ci',
|
|
362
|
+
pr: 'pr',
|
|
363
|
+
issue: 'issue',
|
|
364
|
+
incident: 'incident',
|
|
365
|
+
heartbeat_miss: 'heartbeat',
|
|
366
|
+
file: 'file'
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
|
|
370
|
+
* задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
|
|
371
|
+
* карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
|
|
372
|
+
* выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
|
|
373
|
+
* потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
|
|
374
|
+
* заголовок и есть единственное стабильное поле).
|
|
375
|
+
*/
|
|
216
376
|
export const eventKey = (e) => {
|
|
217
377
|
const fallback = () => {
|
|
218
378
|
switch (e.type) {
|
|
379
|
+
case 'ci':
|
|
380
|
+
return slug(e.branch || e.project);
|
|
381
|
+
case 'deploy':
|
|
382
|
+
return slug(e.target || e.project);
|
|
219
383
|
case 'job':
|
|
220
384
|
case 'heartbeat_miss':
|
|
221
385
|
return slug(e.job);
|
|
@@ -224,21 +388,20 @@ export const eventKey = (e) => {
|
|
|
224
388
|
case 'file':
|
|
225
389
|
return slug(e.title);
|
|
226
390
|
case 'pr':
|
|
227
|
-
return `
|
|
391
|
+
return `p${e.number}`;
|
|
228
392
|
case 'issue':
|
|
229
|
-
return `
|
|
230
|
-
default:
|
|
231
|
-
return e.type;
|
|
393
|
+
return `i${e.number}`;
|
|
232
394
|
}
|
|
233
395
|
};
|
|
234
396
|
return e.key ? slug(e.key) : fallback();
|
|
235
397
|
};
|
|
236
|
-
const
|
|
398
|
+
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
|
|
237
399
|
/**
|
|
238
400
|
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
401
|
+
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
|
402
|
+
* несут и человеческий фильтр, и машинный ключ разборщика — обрезанная
|
|
403
|
+
* карточка без них была бы не только некликабельной, но и невидимой
|
|
404
|
+
* разборщику ровно на самых длинных, то есть самых важных сообщениях.
|
|
242
405
|
*/
|
|
243
406
|
export const render = (e) => {
|
|
244
407
|
const renderer = RENDERERS[e.type];
|
|
@@ -248,10 +411,10 @@ export const render = (e) => {
|
|
|
248
411
|
if (typeof renderer !== 'function') {
|
|
249
412
|
throw new Error(`неизвестный тип события: ${String(e.type)}`);
|
|
250
413
|
}
|
|
251
|
-
const
|
|
414
|
+
const tags = tagsLine(e);
|
|
252
415
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
|
253
416
|
// многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
|
|
254
417
|
// есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
|
|
255
|
-
const budget = Math.max(64, e.type === 'file' ? 1024 -
|
|
256
|
-
return `${clampMessage(renderer(e), budget)}
|
|
418
|
+
const budget = Math.max(64, e.type === 'file' ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
|
|
419
|
+
return `${tags}\n${clampMessage(renderer(e), budget)}`;
|
|
257
420
|
};
|
package/package.json
CHANGED