@mikitasazan/notify 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-flags.d.ts +11 -0
- package/dist/cli-flags.js +19 -0
- package/dist/cli.js +67 -20
- package/dist/events.d.ts +19 -7
- package/dist/render.js +141 -44
- package/dist/send.js +14 -14
- 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,16 +21,30 @@
|
|
|
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}`);
|
|
29
|
+
/**
|
|
30
|
+
* Anything taken from the caller and echoed into a message goes through this.
|
|
31
|
+
*
|
|
32
|
+
* `sent`, `failed` and `skipped` are a CONTRACT: `notify-fail.sh` greps
|
|
33
|
+
* `^\[notify\] sent$`, the server-side silence watchdog matches `*sent*`, and
|
|
34
|
+
* `action.yml` warns on `*failed*|*skipped*`. An input value carrying one of
|
|
35
|
+
* those words puts it on stderr — `notify sent --project=x` printed
|
|
36
|
+
* `unknown event type: sent`, and the watchdog read a card that was never
|
|
37
|
+
* delivered as delivered, then stopped repeating the alarm. Found by Codex,
|
|
38
|
+
* 25.08.2026.
|
|
39
|
+
*/
|
|
40
|
+
const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
|
|
27
41
|
const args = process.argv.slice(2);
|
|
28
42
|
const command = args[0];
|
|
29
43
|
if (command === 'setup') {
|
|
30
44
|
const [, chatId, projectKey] = args;
|
|
31
45
|
if (!chatId || !projectKey) {
|
|
32
|
-
log('
|
|
33
|
-
log('
|
|
46
|
+
log('usage: notify setup <forum chat_id> <project key>');
|
|
47
|
+
log(' create the group first, turn Topics on in it, add the bot as admin');
|
|
34
48
|
process.exit(0);
|
|
35
49
|
}
|
|
36
50
|
await setupTopic(chatId, projectKey);
|
|
@@ -39,11 +53,11 @@ if (command === 'setup') {
|
|
|
39
53
|
const flags = new Map();
|
|
40
54
|
const parseErrors = [];
|
|
41
55
|
/** Флаги без значения. Всё остальное обязано его иметь. */
|
|
42
|
-
const BOOLEAN_FLAGS = new Set(['json', 'recovered']);
|
|
56
|
+
const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
|
|
43
57
|
for (let i = 1; i < args.length; i++) {
|
|
44
58
|
const arg = args[i];
|
|
45
59
|
if (!arg.startsWith('--')) {
|
|
46
|
-
parseErrors.push(
|
|
60
|
+
parseErrors.push(`stray argument with no flag: "${safe(arg)}"`);
|
|
47
61
|
continue;
|
|
48
62
|
}
|
|
49
63
|
// Форма `--key=value` обязательна для значений, начинающихся с `--`
|
|
@@ -65,19 +79,24 @@ for (let i = 1; i < args.length; i++) {
|
|
|
65
79
|
// ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
|
|
66
80
|
// успешном деплое. Теперь это явная ошибка разбора.
|
|
67
81
|
if (next === undefined || next.startsWith('--')) {
|
|
68
|
-
parseErrors.push(
|
|
82
|
+
parseErrors.push(`flag --${safe(key)} with no value`);
|
|
69
83
|
continue;
|
|
70
84
|
}
|
|
71
85
|
i++;
|
|
72
86
|
flags.set(key, [...(flags.get(key) ?? []), next]);
|
|
73
87
|
}
|
|
74
88
|
const one = (key) => flags.get(key)?.[0];
|
|
89
|
+
for (const key of flags.keys()) {
|
|
90
|
+
if (!KNOWN_FLAGS.has(key)) {
|
|
91
|
+
parseErrors.push(`unknown flag --${safe(key)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
75
94
|
// Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
|
|
76
95
|
const num = (key) => {
|
|
77
96
|
const raw = one(key);
|
|
78
97
|
const n = Number(raw);
|
|
79
98
|
if (raw === undefined || Number.isNaN(n)) {
|
|
80
|
-
parseErrors.push(`--${key}:
|
|
99
|
+
parseErrors.push(`--${safe(key)}: expected a number, got "${safe(raw)}"`);
|
|
81
100
|
return 0;
|
|
82
101
|
}
|
|
83
102
|
return n;
|
|
@@ -114,7 +133,7 @@ const ISSUE_ALIASES = {
|
|
|
114
133
|
const prAction = (raw) => {
|
|
115
134
|
const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
|
|
116
135
|
if (!hit) {
|
|
117
|
-
parseErrors.push(`--action:
|
|
136
|
+
parseErrors.push(`--action: unknown PR action "${safe(raw)}" (${Object.keys(PR_ALIASES).join(', ')})`);
|
|
118
137
|
return 'opened';
|
|
119
138
|
}
|
|
120
139
|
return hit;
|
|
@@ -122,7 +141,7 @@ const prAction = (raw) => {
|
|
|
122
141
|
const issueAction = (raw) => {
|
|
123
142
|
const hit = ISSUE_ALIASES[(raw ?? '').toLowerCase()];
|
|
124
143
|
if (!hit) {
|
|
125
|
-
parseErrors.push(`--action:
|
|
144
|
+
parseErrors.push(`--action: unknown issue action "${raw ?? ''}" (${Object.keys(ISSUE_ALIASES).join(', ')})`);
|
|
126
145
|
return 'opened';
|
|
127
146
|
}
|
|
128
147
|
return hit;
|
|
@@ -157,7 +176,9 @@ if (flags.has('json')) {
|
|
|
157
176
|
event = { ...payload, type: command };
|
|
158
177
|
}
|
|
159
178
|
catch (err) {
|
|
160
|
-
|
|
179
|
+
// Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
|
|
180
|
+
// разбор JSON означал бы зелёный крон без дневного отчёта.
|
|
181
|
+
parseErrors.push(`--json from stdin did not parse: ${safe(err instanceof Error ? err.message : err)}`);
|
|
161
182
|
}
|
|
162
183
|
}
|
|
163
184
|
else {
|
|
@@ -183,7 +204,7 @@ else {
|
|
|
183
204
|
event = {
|
|
184
205
|
type: 'job',
|
|
185
206
|
project: project(),
|
|
186
|
-
job: one('job') ?? '(
|
|
207
|
+
job: one('job') ?? '(no name)',
|
|
187
208
|
status: jobStatus(),
|
|
188
209
|
stats: pairs('stat'),
|
|
189
210
|
items: items(),
|
|
@@ -197,7 +218,7 @@ else {
|
|
|
197
218
|
event = {
|
|
198
219
|
type: 'report',
|
|
199
220
|
project: project(),
|
|
200
|
-
title: one('title') ?? '(
|
|
221
|
+
title: one('title') ?? '(no title)',
|
|
201
222
|
period: one('period'),
|
|
202
223
|
lines: pairs('line'),
|
|
203
224
|
items: items(),
|
|
@@ -215,6 +236,7 @@ else {
|
|
|
215
236
|
commitTitle: one('commit-title'),
|
|
216
237
|
commitBody: one('commit-body'),
|
|
217
238
|
actor: one('actor'),
|
|
239
|
+
note: one('note'),
|
|
218
240
|
workflowUrl: one('workflow-url'),
|
|
219
241
|
workflowName: one('workflow-name'),
|
|
220
242
|
url: one('url')
|
|
@@ -226,7 +248,8 @@ else {
|
|
|
226
248
|
project: project(),
|
|
227
249
|
action: prAction(one('action')),
|
|
228
250
|
number: num('number'),
|
|
229
|
-
title: one('title') ?? '(
|
|
251
|
+
title: one('title') ?? '(no title)',
|
|
252
|
+
body: one('body'),
|
|
230
253
|
author: one('author'),
|
|
231
254
|
reviewer: one('reviewer'),
|
|
232
255
|
url: one('url')
|
|
@@ -238,7 +261,7 @@ else {
|
|
|
238
261
|
project: project(),
|
|
239
262
|
action: issueAction(one('action')),
|
|
240
263
|
number: num('number'),
|
|
241
|
-
title: one('title') ?? '(
|
|
264
|
+
title: one('title') ?? '(no title)',
|
|
242
265
|
body: one('body'),
|
|
243
266
|
author: one('author'),
|
|
244
267
|
assignee: one('assignee'),
|
|
@@ -249,7 +272,7 @@ else {
|
|
|
249
272
|
event = {
|
|
250
273
|
type: 'incident',
|
|
251
274
|
project: project(),
|
|
252
|
-
title: one('title') ?? '(
|
|
275
|
+
title: one('title') ?? '(no title)',
|
|
253
276
|
detail: one('detail'),
|
|
254
277
|
logs: one('logs'),
|
|
255
278
|
url: one('url')
|
|
@@ -259,7 +282,7 @@ else {
|
|
|
259
282
|
event = {
|
|
260
283
|
type: 'heartbeat_miss',
|
|
261
284
|
project: project(),
|
|
262
|
-
job: one('job') ?? '(
|
|
285
|
+
job: one('job') ?? '(no name)',
|
|
263
286
|
lastSeen: one('last-seen'),
|
|
264
287
|
expected: one('expected'),
|
|
265
288
|
recovered: flags.has('recovered'),
|
|
@@ -269,12 +292,12 @@ else {
|
|
|
269
292
|
case 'file': {
|
|
270
293
|
const path = one('path');
|
|
271
294
|
if (!path) {
|
|
272
|
-
parseErrors.push('--path:
|
|
295
|
+
parseErrors.push('--path: required for a file event');
|
|
273
296
|
}
|
|
274
297
|
event = {
|
|
275
298
|
type: 'file',
|
|
276
299
|
project: project(),
|
|
277
|
-
title: one('title') ?? '(
|
|
300
|
+
title: one('title') ?? '(no title)',
|
|
278
301
|
path: path ?? '',
|
|
279
302
|
filename: one('filename'),
|
|
280
303
|
note: one('note')
|
|
@@ -282,7 +305,10 @@ else {
|
|
|
282
305
|
break;
|
|
283
306
|
}
|
|
284
307
|
default:
|
|
285
|
-
|
|
308
|
+
// В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
|
|
309
|
+
// тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
|
|
310
|
+
// нулём, ничего не отправив и ничего об этом не сказав.
|
|
311
|
+
parseErrors.push(`unknown event type: ${safe(command ?? '(none given)')}`);
|
|
286
312
|
}
|
|
287
313
|
}
|
|
288
314
|
// --key применим к любому типу — одна точка вместо строки в каждом case
|
|
@@ -297,7 +323,26 @@ if (parseErrors.length > 0) {
|
|
|
297
323
|
for (const err of parseErrors) {
|
|
298
324
|
log(err);
|
|
299
325
|
}
|
|
300
|
-
|
|
326
|
+
// The word `failed` is a CONTRACT, not prose. Two readers match on it: the
|
|
327
|
+
// GitHub Action turns it into a yellow annotation, and the VPS watchdog reads
|
|
328
|
+
// the combined stream for `sent|failed|skipped`. Before this, a parse error
|
|
329
|
+
// printed neither word — the run stayed green, the watchdog stayed quiet, and
|
|
330
|
+
// the card simply never existed.
|
|
331
|
+
// It must NOT contain the substring `sent`: heartbeat-check.sh matches `*sent*`
|
|
332
|
+
// and would read a failure as a success.
|
|
333
|
+
log('failed: bad command, nothing delivered');
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
if (event && flags.has('dry-run')) {
|
|
337
|
+
// The rendered card on STDOUT, nothing sent and no token needed. This is how
|
|
338
|
+
// a change to the format is shown to the owner before it reaches a forum, and
|
|
339
|
+
// how ~25 edited call sites are checked one by one — the package always exits
|
|
340
|
+
// 0, so a typo in a flag is otherwise silent.
|
|
341
|
+
//
|
|
342
|
+
// stdout, not stderr: every other line this CLI prints goes to stderr, and
|
|
343
|
+
// watchdogs read that stream for the words `sent|failed|skipped`. A card
|
|
344
|
+
// printed there would be read as a verdict.
|
|
345
|
+
process.stdout.write(`${render(event)}\n`);
|
|
301
346
|
process.exit(0);
|
|
302
347
|
}
|
|
303
348
|
if (event) {
|
|
@@ -308,7 +353,9 @@ if (event) {
|
|
|
308
353
|
log(await notify(event));
|
|
309
354
|
}
|
|
310
355
|
catch (err) {
|
|
311
|
-
|
|
356
|
+
// Слово `failed` — контракт, тот же, что у ошибки разбора выше. Без него
|
|
357
|
+
// исключение при отправке читалось сторожами как «ничего не случилось».
|
|
358
|
+
log(`failed: ${safe(err instanceof Error ? err.message : err)}`);
|
|
312
359
|
}
|
|
313
360
|
}
|
|
314
361
|
process.exit(0);
|
package/dist/events.d.ts
CHANGED
|
@@ -49,12 +49,12 @@ export type NotifyEvent = Keyed & (
|
|
|
49
49
|
commit?: string;
|
|
50
50
|
/** Ссылка на коммит — строка «коммит» становится кликабельной. */
|
|
51
51
|
commitUrl?: string;
|
|
52
|
-
/** Заголовок коммита — рендерится
|
|
52
|
+
/** Заголовок коммита — рендерится полем `Title:`, тело идёт цитатой ниже. */
|
|
53
53
|
commitTitle?: string;
|
|
54
54
|
/** Тело коммита, если есть — та же цитата, что и заголовок. */
|
|
55
55
|
commitBody?: string;
|
|
56
56
|
workflowUrl?: string;
|
|
57
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
57
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
58
58
|
workflowName?: string;
|
|
59
59
|
url?: string;
|
|
60
60
|
/**
|
|
@@ -85,8 +85,13 @@ export type NotifyEvent = Keyed & (
|
|
|
85
85
|
items?: Item[];
|
|
86
86
|
note?: string;
|
|
87
87
|
workflowUrl?: string;
|
|
88
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
88
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
89
89
|
workflowName?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Запасное имя для ссылки на прогон: половина отправителей шлёт её как
|
|
92
|
+
* `--url`. Рендер берёт `workflowUrl ?? url`, так что оба имени работают.
|
|
93
|
+
* В новых вызовах предпочитай `workflowUrl` — оно говорит, куда ведёт.
|
|
94
|
+
*/
|
|
90
95
|
url?: string;
|
|
91
96
|
}
|
|
92
97
|
/** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
|
|
@@ -124,14 +129,19 @@ export type NotifyEvent = Keyed & (
|
|
|
124
129
|
commit?: string;
|
|
125
130
|
/** Ссылка на коммит — хэш становится кликабельным. */
|
|
126
131
|
commitUrl?: string;
|
|
127
|
-
/** Заголовок коммита (subject) —
|
|
132
|
+
/** Заголовок коммита (subject) — отдельное поле `Title:`, не цитата. */
|
|
128
133
|
commitTitle?: string;
|
|
129
134
|
/** Тело коммита (после subject) — та же цитата, что и заголовок. */
|
|
130
135
|
commitBody?: string;
|
|
131
136
|
actor?: string;
|
|
132
|
-
/**
|
|
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` — запасной для `workflowUrl`. */
|
|
133
143
|
workflowUrl?: string;
|
|
134
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
144
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
135
145
|
workflowName?: string;
|
|
136
146
|
url?: string;
|
|
137
147
|
}
|
|
@@ -146,6 +156,8 @@ export type NotifyEvent = Keyed & (
|
|
|
146
156
|
action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
|
|
147
157
|
number: number;
|
|
148
158
|
title: string;
|
|
159
|
+
/** PR description — quoted on its own; the title is the `Title:` field above it. */
|
|
160
|
+
body?: string;
|
|
149
161
|
author?: string;
|
|
150
162
|
reviewer?: string;
|
|
151
163
|
url?: string;
|
|
@@ -157,7 +169,7 @@ export type NotifyEvent = Keyed & (
|
|
|
157
169
|
action: 'opened' | 'assigned' | 'closed';
|
|
158
170
|
number: number;
|
|
159
171
|
title: string;
|
|
160
|
-
/** Тело задачи —
|
|
172
|
+
/** Тело задачи — цитата под полем `Title:`, отдельно от заголовка. */
|
|
161
173
|
body?: string;
|
|
162
174
|
author?: string;
|
|
163
175
|
assignee?: string;
|
package/dist/render.js
CHANGED
|
@@ -41,12 +41,32 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
41
41
|
// blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
|
|
42
42
|
// прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
|
|
43
43
|
// цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
// `u` в списке с 2026-08-25: заголовок группы рисуется как `<i><u>…</u></i>`,
|
|
45
|
+
// и обрезанный посередине длинный заголовок оставлял `<u>` незакрытым.
|
|
46
|
+
// Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
|
|
47
|
+
// отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
|
|
48
|
+
// отчётом с именем группы в 5000 знаков.
|
|
49
|
+
//
|
|
50
|
+
// Порядок закрытия — обратный порядку ОТКРЫТИЯ, а не фиксированный список.
|
|
51
|
+
// Фиксированный список закрывал `<i><u>` как `</i></u>`: тегов поровну,
|
|
52
|
+
// счётчик сходится, вложенность нарушена, и Telegram отвечает тем же 400.
|
|
53
|
+
// Второй заход того же бага (25.08.2026), поэтому теперь порядок берётся из
|
|
54
|
+
// самого текста: последний открытый закрывается первым.
|
|
55
|
+
const open = [];
|
|
56
|
+
const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
|
|
57
|
+
for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
|
|
58
|
+
if (m[1] === '/') {
|
|
59
|
+
const at = open.lastIndexOf(m[2]);
|
|
60
|
+
if (at !== -1) {
|
|
61
|
+
open.splice(at, 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
open.push(m[2]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const tail = open
|
|
69
|
+
.reverse()
|
|
50
70
|
.map((t) => `</${t}>`)
|
|
51
71
|
.join('');
|
|
52
72
|
return `${body}${tail}\n…`;
|
|
@@ -79,14 +99,23 @@ const fieldLink = (label, url, text) => {
|
|
|
79
99
|
if (text === undefined || text === null || text === '') {
|
|
80
100
|
return null;
|
|
81
101
|
}
|
|
82
|
-
|
|
102
|
+
// `firstLine` here for the same reason `field` has it: the linked case used to
|
|
103
|
+
// skip it, so a multi-line value (arvent's two-line `commit`) became two-line
|
|
104
|
+
// LINK TEXT instead of one identifier.
|
|
105
|
+
return url
|
|
106
|
+
? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
|
|
107
|
+
: field(label, text);
|
|
83
108
|
};
|
|
84
109
|
/**
|
|
85
110
|
* Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
|
|
86
111
|
* поле — прогону просто некуда вести, показывать голое слово «run» без
|
|
87
112
|
* ссылки бессмысленнее, чем не показывать строку вовсе.
|
|
88
113
|
*/
|
|
89
|
-
|
|
114
|
+
// Текст ссылки — имя того, куда она ведёт (имя workflow, имя прогона). Запасное
|
|
115
|
+
// слово было «run»: существительное, которое ничего не называет — владелец читал
|
|
116
|
+
// «Workflow: run» и не понимал, что это. «open» — глагол, он хотя бы честно
|
|
117
|
+
// говорит, что это ссылка, а не название.
|
|
118
|
+
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
90
119
|
/** Моноширинное поле — путь/команда для копирования, не ссылка. */
|
|
91
120
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
92
121
|
/** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
|
|
@@ -120,56 +149,90 @@ const renderGroup = (g) => [
|
|
|
120
149
|
...g.items.map((it, i) => groupItem(it, i, false))
|
|
121
150
|
];
|
|
122
151
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
152
|
+
* ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок —
|
|
153
|
+
* обычное поле `Title:`, тело — цитата, и в цитате больше ничего нет.
|
|
154
|
+
*
|
|
155
|
+
* Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
|
|
156
|
+
* строкой. Владелец нашёл, чем это плохо: заголовок — главное в карточке, то,
|
|
157
|
+
* ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
|
|
158
|
+
* одно от другого можно было только по пустой строке. У PR без тела карточка
|
|
159
|
+
* вырождалась в одинокую серую цитату из одной строки.
|
|
160
|
+
*
|
|
161
|
+
* Заголовок режется до первой строки: многострочный subject коммита не должен
|
|
162
|
+
* затягивать в поле собственное тело.
|
|
126
163
|
*/
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
const text = [title ? firstLine(title) : undefined, body].filter(Boolean).join('\n\n');
|
|
132
|
-
return note(text);
|
|
133
|
-
};
|
|
164
|
+
const titleField = (title) => field('Title', title);
|
|
165
|
+
const bodyQuote = (body) => body ? note(body) : null;
|
|
134
166
|
// Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
|
|
135
167
|
// закреплённая легенда в форумах обещает это владельцу как факт, не как
|
|
136
168
|
// приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
|
|
137
169
|
const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
|
|
138
170
|
/** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
|
|
139
|
-
|
|
171
|
+
// `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
|
|
172
|
+
// JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
|
|
173
|
+
// прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
|
|
174
|
+
const typeLine = (icon, type, action) => {
|
|
175
|
+
// `field` возвращает null на пустом значении, а интерполяция null в шаблон
|
|
176
|
+
// печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
|
|
177
|
+
// достижимо через `--json` и прямой вызов на JS, где типов нет.
|
|
178
|
+
const line = field(type, action);
|
|
179
|
+
return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
|
|
180
|
+
};
|
|
181
|
+
// `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
|
|
182
|
+
// `--url` — это имя было в пакете раньше и осталось в вызовах. Рендер читал
|
|
183
|
+
// только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
|
|
184
|
+
// на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
|
|
185
|
+
// однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
|
|
140
186
|
const renderDeploy = (e) => {
|
|
141
187
|
const icon = e.status === 'ok' ? ICON.ok : ICON.red;
|
|
142
188
|
return join([
|
|
143
189
|
typeLine(icon, 'Deploy', e.status),
|
|
144
190
|
'',
|
|
145
191
|
fieldLink('Commit', e.commitUrl, e.commit),
|
|
146
|
-
|
|
192
|
+
titleField(e.commitTitle),
|
|
193
|
+
bodyQuote(e.commitBody),
|
|
147
194
|
field('Via', e.via),
|
|
148
195
|
field('Target', e.target),
|
|
149
196
|
field('Reason', e.note),
|
|
150
|
-
e.workflowUrl ? '' : null,
|
|
151
|
-
fieldAction('Workflow', e.workflowUrl, e.workflowName)
|
|
197
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
198
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
152
199
|
]);
|
|
153
200
|
};
|
|
154
201
|
const renderJob = (e) => {
|
|
155
202
|
const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
|
|
156
203
|
const hasItems = (e.items ?? []).length > 0;
|
|
204
|
+
const disabledList = hasItems && e.status === 'disabled';
|
|
157
205
|
return join([
|
|
158
206
|
typeLine(icon, 'Job', e.status),
|
|
159
207
|
'',
|
|
208
|
+
// The name is NOT the type line: line 2 is `Job: fail` by the format's own
|
|
209
|
+
// rule, so the name is its own field. Called `Task:` and not `Job:` because
|
|
210
|
+
// repeating the label of the line right above it reads as a mistake.
|
|
211
|
+
// Until now the name was dropped entirely — every caller passed it and the
|
|
212
|
+
// owner only ever saw it as the small grey instance tag.
|
|
213
|
+
field('Task', e.job),
|
|
160
214
|
field('Reason', e.note),
|
|
161
215
|
...(e.stats ?? []).map(([label, value]) => field(label, value)),
|
|
162
216
|
hasItems ? '' : null,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
218
|
+
// list, so playhub's daily card of newly published games was headed
|
|
219
|
+
// "Disabled workflows".
|
|
220
|
+
disabledList ? group('Disabled workflows') : null,
|
|
221
|
+
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
222
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
223
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
167
224
|
]);
|
|
168
225
|
};
|
|
169
226
|
const renderReport = (e) => {
|
|
170
227
|
if (e.groups && e.groups.length > 0) {
|
|
171
228
|
const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
|
|
172
|
-
return join([
|
|
229
|
+
return join([
|
|
230
|
+
typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
|
|
231
|
+
'',
|
|
232
|
+
...body,
|
|
233
|
+
e.url ? '' : null,
|
|
234
|
+
fieldAction('Details', e.url, undefined)
|
|
235
|
+
]);
|
|
173
236
|
}
|
|
174
237
|
const items = bullets(e.items, false);
|
|
175
238
|
return join([
|
|
@@ -177,7 +240,11 @@ const renderReport = (e) => {
|
|
|
177
240
|
'',
|
|
178
241
|
...(e.lines ?? []).map(([label, value]) => field(label, value)),
|
|
179
242
|
items.length > 0 ? '' : null,
|
|
180
|
-
...items
|
|
243
|
+
...items,
|
|
244
|
+
// Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
|
|
245
|
+
// и дневной отчёт приходил без единственного способа посмотреть подробности.
|
|
246
|
+
e.url ? '' : null,
|
|
247
|
+
fieldAction('Details', e.url, undefined)
|
|
181
248
|
]);
|
|
182
249
|
};
|
|
183
250
|
const renderCi = (e) => {
|
|
@@ -186,10 +253,12 @@ const renderCi = (e) => {
|
|
|
186
253
|
typeLine(icon, 'CI', e.status),
|
|
187
254
|
'',
|
|
188
255
|
fieldLink('Commit', e.commitUrl, e.commit),
|
|
189
|
-
|
|
256
|
+
titleField(e.commitTitle),
|
|
257
|
+
bodyQuote(e.commitBody),
|
|
190
258
|
field('Actor', e.actor),
|
|
191
|
-
|
|
192
|
-
|
|
259
|
+
field('Reason', e.note),
|
|
260
|
+
e.workflowUrl ?? e.url ? '' : null,
|
|
261
|
+
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
193
262
|
]);
|
|
194
263
|
};
|
|
195
264
|
// PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
|
|
@@ -213,39 +282,67 @@ const ISSUE_ICON = {
|
|
|
213
282
|
const renderPr = (e) => join([
|
|
214
283
|
typeLine(PR_ICON[e.action], 'PR', e.action),
|
|
215
284
|
'',
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
'',
|
|
285
|
+
// Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
|
|
286
|
+
// такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
|
|
287
|
+
fieldLink('Number', e.url, `#${e.number}`),
|
|
288
|
+
titleField(e.title),
|
|
289
|
+
bodyQuote(e.body),
|
|
290
|
+
// Без пустой строки перед автором: у задачи её нет, и одно и то же поле
|
|
291
|
+
// не должно стоять по-разному в двух соседних карточках. Пустая строка в
|
|
292
|
+
// этом формате означает «дальше указатель, куда пойти» — автор не он.
|
|
219
293
|
field('Author', e.author),
|
|
220
294
|
field('Reviewer', e.reviewer)
|
|
221
295
|
]);
|
|
222
296
|
const renderIssue = (e) => join([
|
|
223
297
|
typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
|
|
224
298
|
'',
|
|
225
|
-
fieldLink('
|
|
226
|
-
|
|
299
|
+
fieldLink('Number', e.url, `#${e.number}`),
|
|
300
|
+
titleField(e.title),
|
|
301
|
+
bodyQuote(e.body),
|
|
227
302
|
field('Author', e.author),
|
|
228
303
|
field('Assignee', e.assignee)
|
|
229
304
|
]);
|
|
230
305
|
const renderIncident = (e) => join([
|
|
231
306
|
typeLine(ICON.alarm, 'Incident', 'open'),
|
|
232
307
|
'',
|
|
233
|
-
|
|
308
|
+
// `detail` is a diagnosis of several lines (vault greps three of them plus a
|
|
309
|
+
// log path). It used to go through `field`, which keeps only the first line,
|
|
310
|
+
// so every alarm this package ever sent arrived gutted. Same shape as a
|
|
311
|
+
// commit now: short label, full text quoted under it.
|
|
312
|
+
// Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
|
|
313
|
+
// как у коммита и задачи, и называться в одной карточке он должен так же.
|
|
314
|
+
titleField(e.title),
|
|
315
|
+
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
234
316
|
e.logs || e.url ? '' : null,
|
|
235
317
|
fieldCode('Logs', e.logs),
|
|
236
318
|
fieldAction('Workflow', e.url, undefined)
|
|
237
319
|
]);
|
|
320
|
+
// Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
|
|
321
|
+
// reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
|
|
322
|
+
// свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
|
|
323
|
+
// формат. Отдельного формата больше нет.
|
|
238
324
|
const renderHeartbeatMiss = (e) => {
|
|
239
325
|
const icon = e.recovered ? ICON.ok : ICON.red;
|
|
240
326
|
const action = e.recovered ? 'ok' : 'miss';
|
|
241
|
-
|
|
242
|
-
(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
327
|
+
return join([
|
|
328
|
+
typeLine(icon, 'Heartbeat', action),
|
|
329
|
+
'',
|
|
330
|
+
field('Task', e.job),
|
|
331
|
+
field('Reason', e.note),
|
|
332
|
+
field('Expected', e.expected),
|
|
333
|
+
field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
|
|
334
|
+
]);
|
|
246
335
|
};
|
|
247
336
|
// Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
|
|
248
|
-
const renderFile = (e) => join([
|
|
337
|
+
const renderFile = (e) => join([
|
|
338
|
+
typeLine(ICON.info, 'File', 'new'),
|
|
339
|
+
'',
|
|
340
|
+
// Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
|
|
341
|
+
// приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
|
|
342
|
+
// Один ярлык — один смысл: Title это title, Reason это note.
|
|
343
|
+
field('Title', e.title),
|
|
344
|
+
field('Reason', e.note)
|
|
345
|
+
]);
|
|
249
346
|
const RENDERERS = {
|
|
250
347
|
deploy: renderDeploy,
|
|
251
348
|
job: renderJob,
|
|
@@ -327,7 +424,7 @@ export const render = (e) => {
|
|
|
327
424
|
// строка, и неизвестное значение роняло процесс через `renderer is not a
|
|
328
425
|
// function`. Падать из-за уведомления нельзя.
|
|
329
426
|
if (typeof renderer !== 'function') {
|
|
330
|
-
throw new Error(
|
|
427
|
+
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
331
428
|
}
|
|
332
429
|
const tags = tagsLine(e);
|
|
333
430
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
package/dist/send.js
CHANGED
|
@@ -89,7 +89,7 @@ const attempt = async (token, target, text) => {
|
|
|
89
89
|
// почему уведомления пропали, невозможно — а разбираться будет не
|
|
90
90
|
// разработчик, а владелец.
|
|
91
91
|
const detail = (await res.json().catch(() => null));
|
|
92
|
-
log(`HTTP ${res.status}: ${detail?.description ?? '
|
|
92
|
+
log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
|
|
93
93
|
return { outcome: 'fail' };
|
|
94
94
|
}
|
|
95
95
|
catch (err) {
|
|
@@ -99,7 +99,7 @@ const attempt = async (token, target, text) => {
|
|
|
99
99
|
// на таймауте останавливаемся и честно пишем 'failed': лишняя копия аварии
|
|
100
100
|
// хуже, чем пропущенная строка в логе, а сообщение, скорее всего, ушло.
|
|
101
101
|
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
102
|
-
log('
|
|
102
|
+
log('answer timed out — not retried: the message may already be out');
|
|
103
103
|
return { outcome: 'fail' };
|
|
104
104
|
}
|
|
105
105
|
// Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
|
|
@@ -109,7 +109,7 @@ const attempt = async (token, target, text) => {
|
|
|
109
109
|
// отсутствие дублей невозможно без idempotency-key у Bot API (его нет).
|
|
110
110
|
// Логика прежняя: дубль на редком reset — меньшее зло, чем потеря
|
|
111
111
|
// сообщения на частом сетевом сбое.
|
|
112
|
-
log('fetch
|
|
112
|
+
log('fetch did not go through, trying curl…');
|
|
113
113
|
const curl = sendViaCurl(token, target, text);
|
|
114
114
|
return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
|
|
115
115
|
}
|
|
@@ -130,14 +130,14 @@ const sendOne = async (token, target, text) => {
|
|
|
130
130
|
}
|
|
131
131
|
waitMs = result.waitMs;
|
|
132
132
|
}
|
|
133
|
-
log('
|
|
133
|
+
log('out of send attempts');
|
|
134
134
|
return 'failed';
|
|
135
135
|
};
|
|
136
136
|
/** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
|
|
137
137
|
const deliver = async (where, text) => {
|
|
138
138
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
139
139
|
if (!token) {
|
|
140
|
-
log('
|
|
140
|
+
log('skipped: no OPS_BOT_TOKEN, the message was not sent');
|
|
141
141
|
return 'skipped';
|
|
142
142
|
}
|
|
143
143
|
// Токен interpolируется в URL и в curl-конфиг (`url = "...bot${token}..."`).
|
|
@@ -145,7 +145,7 @@ const deliver = async (where, text) => {
|
|
|
145
145
|
// строки/`?` сломало бы разбор (инъекция директивы curl или query-хвост).
|
|
146
146
|
// Это требует покорёженного секрета, но проверка копеечная.
|
|
147
147
|
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
|
148
|
-
log('OPS_BOT_TOKEN
|
|
148
|
+
log('failed: OPS_BOT_TOKEN does not look like a Telegram token, send cancelled');
|
|
149
149
|
return 'skipped';
|
|
150
150
|
}
|
|
151
151
|
if (where.length === 0) {
|
|
@@ -196,27 +196,27 @@ const sendFileOnce = async (token, target, e, caption) => {
|
|
|
196
196
|
return { outcome: 'retry', waitMs: 1000 };
|
|
197
197
|
}
|
|
198
198
|
const detail = (await res.json().catch(() => null));
|
|
199
|
-
log(`HTTP ${res.status}: ${detail?.description ?? '
|
|
199
|
+
log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
|
|
200
200
|
return { outcome: 'fail' };
|
|
201
201
|
}
|
|
202
202
|
catch (err) {
|
|
203
203
|
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
204
|
-
log('
|
|
204
|
+
log('answer timed out — not retried: the file may already be out');
|
|
205
205
|
return { outcome: 'fail' };
|
|
206
206
|
}
|
|
207
207
|
// Файл не читается (нет на диске, нет прав) — постоянная ошибка.
|
|
208
208
|
if (err instanceof Error && 'code' in err) {
|
|
209
|
-
log(
|
|
209
|
+
log(`failed to send the file: ${err.message}`);
|
|
210
210
|
return { outcome: 'fail' };
|
|
211
211
|
}
|
|
212
|
-
log(
|
|
212
|
+
log(`the network refused the file: ${err instanceof Error ? err.message : String(err)}`);
|
|
213
213
|
return { outcome: 'retry', waitMs: 1000 };
|
|
214
214
|
}
|
|
215
215
|
};
|
|
216
216
|
const sendFile = async (e) => {
|
|
217
217
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
218
218
|
if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
|
219
|
-
log('
|
|
219
|
+
log('skipped: no valid OPS_BOT_TOKEN, the file was not sent');
|
|
220
220
|
return 'skipped';
|
|
221
221
|
}
|
|
222
222
|
const where = targets(e);
|
|
@@ -261,13 +261,13 @@ const sendFile = async (e) => {
|
|
|
261
261
|
const reportLostProject = async (project, kind) => {
|
|
262
262
|
// Локальный лог называет и допустимые написания — это единственная
|
|
263
263
|
// диагностика, доступная на машине, где случилась опечатка.
|
|
264
|
-
log(
|
|
264
|
+
log(`unknown project "${String(project)}" — known: ${Object.keys(ROUTES).join(', ')}`);
|
|
265
265
|
const lost = {
|
|
266
266
|
type: 'job',
|
|
267
267
|
project: 'mac-config',
|
|
268
|
-
job: 'notify:
|
|
268
|
+
job: 'notify: an event was lost',
|
|
269
269
|
status: 'fail',
|
|
270
|
-
note:
|
|
270
|
+
note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
|
|
271
271
|
key: 'notify-unknown-project'
|
|
272
272
|
};
|
|
273
273
|
await deliver(targets(lost), render(lost)).catch(() => undefined);
|
package/package.json
CHANGED