@mikitasazan/notify 1.4.1 → 1.4.2
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/README.md +22 -16
- package/dist/cli-flags.js +3 -2
- package/dist/cli.js +41 -12
- package/dist/events.d.ts +127 -22
- package/dist/events.js +77 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/render.d.ts +10 -1
- package/dist/render.js +176 -73
- package/dist/routes.js +1 -1
- package/dist/send.js +7 -5
- package/dist/setup.js +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,36 +85,42 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
85
85
|
| `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
|
|
86
86
|
| `issue` | событие задачи | `project`, `action`, `number`, `title` |
|
|
87
87
|
| `incident` | приложение сломалось прямо сейчас | `project`, `title` |
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
88
|
+
| `session` | рабочая сессия на маке в беде | `project`, `action` |
|
|
89
|
+
| `heartbeat_miss` | УСТАРЕЛ, молчание — это `job --status silent` | `project`, `job` |
|
|
90
|
+
|
|
91
|
+
Отдельного вида «файл» нет: `--path` применим к ЛЮБОМУ событию, и тогда
|
|
92
|
+
карточка едет подписью к вложению (подпись у Telegram ограничена 1024 знаками,
|
|
93
|
+
а не 4000). Слово `file` осталось псевдонимом и собирает `report` с вложением —
|
|
94
|
+
старый отправитель не замолкает.
|
|
90
95
|
|
|
91
96
|
Полные сигнатуры — `src/events.ts`.
|
|
92
97
|
|
|
93
|
-
**Ключ задачи.**
|
|
94
|
-
По
|
|
98
|
+
**Ключ задачи.** Первая строка каждой карточки — два тега: `#тип #ключ`.
|
|
99
|
+
По ним дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
|
|
95
100
|
той же задачи?» без сравнения человеческих формулировок. Явный `--key`
|
|
96
101
|
побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
|
|
97
102
|
регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
|
|
98
103
|
|
|
99
104
|
**Единственная дверь для свободного HTML** — `sendReport()` (`report --json` у
|
|
100
105
|
аналитик): плотную строку дневного отчёта не разложить в `label=value` не
|
|
101
|
-
испортив. Стандартизирован транспорт, формат — нет, и только здесь.
|
|
106
|
+
испортив. Стандартизирован транспорт, формат тела — нет, и только здесь.
|
|
107
|
+
Строку тегов `#report #ключ` пакет ставит и здесь: тег — фильтр владельца, к
|
|
108
|
+
формату тела он отношения не имеет. Без явного ключа берётся `daily`.
|
|
102
109
|
|
|
103
110
|
**Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
|
|
104
111
|
не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
|
|
105
112
|
потеряно». До 18.08.2026 опечатка в `--project` терялась без следа неделями.
|
|
106
113
|
|
|
107
|
-
`--action` у `pr`: `opened`, `
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
`
|
|
111
|
-
подмена: иначе «запрошены правки» приехали бы как «открыт».
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
|
|
114
|
+
`--action` у `pr`: `opened`, `approved`, `changes_requested`, `merged`,
|
|
115
|
+
`closed`. У `issue`: `opened`, `assigned`, `closed`. Сырые имена GitHub, которые
|
|
116
|
+
означают то же самое, сводятся к ним: `reopened`, `ready_for_review` и
|
|
117
|
+
`review_requested` — это всё `opened`. Неизвестное действие — ошибка разбора, а
|
|
118
|
+
не молчаливая подмена: иначе «запрошены правки» приехали бы как «открыт».
|
|
119
|
+
|
|
120
|
+
**Из GitHub в «⚙️ Ops» уходит не всё, что там происходит.** Общий workflow
|
|
121
|
+
`.github/workflows/ops-notify.yml` отбрасывает `ready_for_review` и
|
|
122
|
+
`review_requested` ещё на входе: оба сообщают про PR, который уже объявлен
|
|
123
|
+
открытым, и открытие одного PR давало три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
|
|
118
124
|
карточка: **открыт → вердикт ревью (👍 / 📝) → закрыт или смёржен**. Белый список
|
|
119
125
|
живёт в общем workflow, а не в подписках проектов, потому что подписка обязана
|
|
120
126
|
лежать в вызывающем репозитории и в четырёх проектах расходится сама собой;
|
package/dist/cli-flags.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
13
|
+
'id', 'opened', 'reason', 'workdir',
|
|
14
|
+
'command', 'command-note', 'commit-body', 'commit-title', 'commit-url', 'detail',
|
|
15
|
+
'expected', 'filename', 'item', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
15
16
|
'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
16
17
|
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
17
18
|
// Флаги без значения. Живут здесь же, чтобы разбор и список не разошлись.
|
package/dist/cli.js
CHANGED
|
@@ -114,8 +114,10 @@ const project = () => one('project');
|
|
|
114
114
|
const PR_ALIASES = {
|
|
115
115
|
opened: 'opened',
|
|
116
116
|
reopened: 'opened',
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// A PR that is already announced is not announced again: both of these mean
|
|
118
|
+
// "this PR now wants eyes", which is what `opened` already says.
|
|
119
|
+
ready_for_review: 'opened',
|
|
120
|
+
review_requested: 'opened',
|
|
119
121
|
approved: 'approved',
|
|
120
122
|
changes_requested: 'changes_requested',
|
|
121
123
|
merged: 'merged',
|
|
@@ -165,6 +167,9 @@ const jobStatus = () => {
|
|
|
165
167
|
if (raw === 'disabled') {
|
|
166
168
|
return 'disabled';
|
|
167
169
|
}
|
|
170
|
+
if (raw === 'silent') {
|
|
171
|
+
return 'silent';
|
|
172
|
+
}
|
|
168
173
|
return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
|
|
169
174
|
};
|
|
170
175
|
let event;
|
|
@@ -206,9 +211,14 @@ else {
|
|
|
206
211
|
project: project(),
|
|
207
212
|
job: one('job') ?? '(no name)',
|
|
208
213
|
status: jobStatus(),
|
|
214
|
+
expected: one('expected'),
|
|
215
|
+
lastSeen: one('last-seen'),
|
|
209
216
|
stats: pairs('stat'),
|
|
210
217
|
items: items(),
|
|
211
218
|
note: one('note'),
|
|
219
|
+
command: one('command'),
|
|
220
|
+
commandNote: one('command-note'),
|
|
221
|
+
logs: one('logs'),
|
|
212
222
|
workflowUrl: one('workflow-url'),
|
|
213
223
|
workflowName: one('workflow-name'),
|
|
214
224
|
url: one('url')
|
|
@@ -268,6 +278,21 @@ else {
|
|
|
268
278
|
url: one('url')
|
|
269
279
|
};
|
|
270
280
|
break;
|
|
281
|
+
case 'session':
|
|
282
|
+
event = {
|
|
283
|
+
type: 'session',
|
|
284
|
+
project: project(),
|
|
285
|
+
action: one('action') ?? 'in trouble',
|
|
286
|
+
id: one('id'),
|
|
287
|
+
workdir: one('workdir'),
|
|
288
|
+
reason: one('reason'),
|
|
289
|
+
opened: one('opened'),
|
|
290
|
+
command: one('command'),
|
|
291
|
+
commandNote: one('command-note'),
|
|
292
|
+
// Only two states here, so `disabled` must not leak in from jobStatus.
|
|
293
|
+
status: jobStatus() === 'ok' ? 'ok' : 'fail'
|
|
294
|
+
};
|
|
295
|
+
break;
|
|
271
296
|
case 'incident':
|
|
272
297
|
event = {
|
|
273
298
|
type: 'incident',
|
|
@@ -289,21 +314,19 @@ else {
|
|
|
289
314
|
note: one('note')
|
|
290
315
|
};
|
|
291
316
|
break;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
317
|
+
// `file` is no longer a kind of event — an attachment is a property any
|
|
318
|
+
// card may have. The word is kept as an alias so senders that still say
|
|
319
|
+
// `notify file` deliver a report card with the log attached, instead of
|
|
320
|
+
// falling into the unknown-type branch and going silent.
|
|
321
|
+
case 'file':
|
|
297
322
|
event = {
|
|
298
|
-
type: '
|
|
323
|
+
type: 'report',
|
|
299
324
|
project: project(),
|
|
300
325
|
title: one('title') ?? '(no title)',
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
note: one('note')
|
|
326
|
+
period: one('note'),
|
|
327
|
+
lines: []
|
|
304
328
|
};
|
|
305
329
|
break;
|
|
306
|
-
}
|
|
307
330
|
default:
|
|
308
331
|
// В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
|
|
309
332
|
// тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
|
|
@@ -316,6 +339,12 @@ else {
|
|
|
316
339
|
// key в самом объекте, отсутствие флага не должно его затирать.
|
|
317
340
|
if (event) {
|
|
318
341
|
event.key = one('key') ?? event.key;
|
|
342
|
+
// --path is applicable to any type too, for the same reason --key is.
|
|
343
|
+
event.path = one('path') ?? event.path;
|
|
344
|
+
event.filename = one('filename') ?? event.filename;
|
|
345
|
+
}
|
|
346
|
+
if (event?.filename && !event.path) {
|
|
347
|
+
parseErrors.push('--filename: given without --path, so there is no file to name');
|
|
319
348
|
}
|
|
320
349
|
// Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
|
|
321
350
|
// чем прислать сообщение с «true» вместо ссылки или 🔴 на успешном деплое.
|
package/dist/events.d.ts
CHANGED
|
@@ -22,8 +22,18 @@ export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault
|
|
|
22
22
|
* потоком по словам `sent|failed|skipped`) ключ не попадает никогда — новое
|
|
23
23
|
* слово там ослепило бы сторожа.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Общая часть любого события. `path` — локальный файл, который едет ВМЕСТЕ с
|
|
27
|
+
* карточкой: карточка становится подписью к вложению. Отдельного вида `file`
|
|
28
|
+
* нет с 25.08.2026 — прогон Arvent слал вердикт и лог двумя карточками про
|
|
29
|
+
* одну новость.
|
|
30
|
+
*/
|
|
25
31
|
type Keyed = {
|
|
26
32
|
key?: string;
|
|
33
|
+
/** Локальный файл; карточка уедет как подпись к нему (лимит подписи 1024). */
|
|
34
|
+
path?: string;
|
|
35
|
+
/** Имя файла в чате; по умолчанию — имя из `path`. */
|
|
36
|
+
filename?: string;
|
|
27
37
|
};
|
|
28
38
|
/**
|
|
29
39
|
* Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
|
|
@@ -78,12 +88,46 @@ export type NotifyEvent = Keyed & (
|
|
|
78
88
|
type: 'job';
|
|
79
89
|
project: Project;
|
|
80
90
|
job: string;
|
|
81
|
-
/**
|
|
82
|
-
|
|
91
|
+
/**
|
|
92
|
+
* `disabled` — задача выключена извне (например GitHub Actions кончил
|
|
93
|
+
* бесплатные минуты), не провалилась сама.
|
|
94
|
+
*
|
|
95
|
+
* `silent` — задача не отчиталась в срок: она не упала, она вообще не
|
|
96
|
+
* подала признаков жизни. Это состояние ЗАДАЧИ, а не отдельный вид
|
|
97
|
+
* события — оно жило типом `heartbeat_miss`, и владелец справедливо
|
|
98
|
+
* спросил, почему задача по расписанию у него под двумя разными тегами.
|
|
99
|
+
* Хуже того: сторож молчания шлёт тот же машинный ключ, что и сама
|
|
100
|
+
* задача, так что красная карточка `#heartbeat #daily_import` не
|
|
101
|
+
* закрывалась зелёной `#job #daily_import` — разборщик ищет пару по
|
|
102
|
+
* ПОЛНОМУ тегу. Один поток на задачу это чинит.
|
|
103
|
+
*/
|
|
104
|
+
status: 'ok' | 'fail' | 'disabled' | 'silent';
|
|
105
|
+
/** Как часто задача обязана отмечаться — для `silent` и для возврата из него. */
|
|
106
|
+
expected?: string;
|
|
107
|
+
/** Когда её видели в последний раз. */
|
|
108
|
+
lastSeen?: string;
|
|
83
109
|
stats?: Array<[label: string, value: string | number]>;
|
|
84
110
|
/** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
|
|
85
111
|
items?: Item[];
|
|
86
112
|
note?: string;
|
|
113
|
+
/**
|
|
114
|
+
* A command for him to run, rendered monospaced so Telegram makes it
|
|
115
|
+
* tap-to-copy. For the case where the card names something on this Mac
|
|
116
|
+
* that no URL can reach — a stopped local session, a latch file.
|
|
117
|
+
*/
|
|
118
|
+
command?: string;
|
|
119
|
+
/**
|
|
120
|
+
* WHAT that command does. The owner, on a bare `rm` in a card: "я сейчас
|
|
121
|
+
* введу её и сделаю хуй пойми что, я ж не знаю, что делаю". A command he
|
|
122
|
+
* cannot read is one he cannot run, so it never travels alone.
|
|
123
|
+
*/
|
|
124
|
+
commandNote?: string;
|
|
125
|
+
/**
|
|
126
|
+
* A local log path — monospaced, not a link, same as on an incident.
|
|
127
|
+
* It used to be glued onto the end of the reason sentence behind a
|
|
128
|
+
* colon, which is what made a red card read as one long run-on line.
|
|
129
|
+
*/
|
|
130
|
+
logs?: string;
|
|
87
131
|
workflowUrl?: string;
|
|
88
132
|
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
89
133
|
workflowName?: string;
|
|
@@ -153,7 +197,7 @@ export type NotifyEvent = Keyed & (
|
|
|
153
197
|
| {
|
|
154
198
|
type: 'pr';
|
|
155
199
|
project: Project;
|
|
156
|
-
action: 'opened' | '
|
|
200
|
+
action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
|
|
157
201
|
number: number;
|
|
158
202
|
title: string;
|
|
159
203
|
/** PR description — quoted on its own; the title is the `Title:` field above it. */
|
|
@@ -185,7 +229,47 @@ export type NotifyEvent = Keyed & (
|
|
|
185
229
|
logs?: string;
|
|
186
230
|
url?: string;
|
|
187
231
|
}
|
|
188
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* A working session on this Mac is in trouble — not a job, not a workflow.
|
|
234
|
+
* It went out as `job` at first and read wrong: `#job` promises something
|
|
235
|
+
* scheduled that ran and failed, and the owner rightly asked what a burning
|
|
236
|
+
* session was doing under that heading.
|
|
237
|
+
*
|
|
238
|
+
* What makes it its own type rather than an `incident`: a session has an
|
|
239
|
+
* identity nothing else here has — an id, a working directory, and the line
|
|
240
|
+
* he typed to start it, which is the ONLY thing that tells two of his open
|
|
241
|
+
* sessions apart.
|
|
242
|
+
*/
|
|
243
|
+
| {
|
|
244
|
+
type: 'session';
|
|
245
|
+
project: Project;
|
|
246
|
+
/** What happened, as the second line reads it: `Session: burning the limit`. */
|
|
247
|
+
action: string;
|
|
248
|
+
/** The session's own id — the identifier field, first, as everywhere else. */
|
|
249
|
+
id?: string;
|
|
250
|
+
/** Working directory name, when several sessions opened with a similar line. */
|
|
251
|
+
workdir?: string;
|
|
252
|
+
/** One line of measurement: what the guard saw. */
|
|
253
|
+
reason?: string;
|
|
254
|
+
/**
|
|
255
|
+
* The line he opened the session with. Quoted, never a field: it is his
|
|
256
|
+
* own writing, it runs long, and a field would clip it to one short line
|
|
257
|
+
* — which is exactly how the first version of this card lost it.
|
|
258
|
+
*/
|
|
259
|
+
opened?: string;
|
|
260
|
+
/** A command for him to run, monospaced so Telegram makes it copyable. */
|
|
261
|
+
command?: string;
|
|
262
|
+
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
263
|
+
commandNote?: string;
|
|
264
|
+
/** `fail` red, `ok` green — a session that recovered is not an alarm. */
|
|
265
|
+
status?: 'fail' | 'ok';
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* УСТАРЕЛО с 1.4.2: используйте `job` со статусом `silent`. Тип остаётся,
|
|
269
|
+
* потому что сторож молчания живёт на сервере и до выкатки шлёт именно его —
|
|
270
|
+
* убрать значит потерять карточку молчания ровно тогда, когда она нужна.
|
|
271
|
+
* Новых вызовов не добавлять.
|
|
272
|
+
*/
|
|
189
273
|
| {
|
|
190
274
|
type: 'heartbeat_miss';
|
|
191
275
|
project: Project;
|
|
@@ -196,26 +280,47 @@ export type NotifyEvent = Keyed & (
|
|
|
196
280
|
recovered?: boolean;
|
|
197
281
|
/** Готовое предложение-причина; без него собирается из lastSeen/expected. */
|
|
198
282
|
note?: string;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
|
|
202
|
-
* eval-отчёт Arvent слал файл голым curl мимо пакета: без ретраев файл
|
|
203
|
-
* терялся в любую сетевую икоту, а chat_id и номер вкладки жили копией,
|
|
204
|
-
* которая устаревает молча. Маршрут — тот же `ROUTES`, транспорт — с теми
|
|
205
|
-
* же повторами. Подпись у Telegram ограничена 1024 символами — режется
|
|
206
|
-
* тем же безопасным клампом.
|
|
207
|
-
*/
|
|
208
|
-
| {
|
|
209
|
-
type: 'file';
|
|
210
|
-
project: Project;
|
|
211
|
-
title: string;
|
|
212
|
-
/** Путь к локальному файлу. */
|
|
213
|
-
path: string;
|
|
214
|
-
/** Имя файла в чате; по умолчанию — имя из `path`. */
|
|
215
|
-
filename?: string;
|
|
216
|
-
note?: string;
|
|
217
283
|
});
|
|
218
284
|
export type EventType = NotifyEvent['type'];
|
|
219
285
|
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
286
|
+
/**
|
|
287
|
+
* Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
|
|
288
|
+
*
|
|
289
|
+
* 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
|
|
290
|
+
* ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
|
|
291
|
+
* выглядели одинаково, а у задачи три разных беды — fail, disabled,
|
|
292
|
+
* silent — были одним и тем же красным кругом.
|
|
293
|
+
* 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
|
|
294
|
+
* выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
|
|
295
|
+
* и у PR, и у файла.
|
|
296
|
+
*
|
|
297
|
+
* И третий, который держит первые два честными: у значка фиксированный звук.
|
|
298
|
+
* Не у события, не у статуса — у значка. Пока звук выводился отдельным
|
|
299
|
+
* правилом, `🔴 PR: changes_requested` приходила беззвучно.
|
|
300
|
+
*/
|
|
301
|
+
export declare const ICON: {
|
|
302
|
+
readonly ok: "✅";
|
|
303
|
+
readonly red: "🔴";
|
|
304
|
+
readonly alarm: "🚨";
|
|
305
|
+
readonly off: "🚫";
|
|
306
|
+
readonly unknown: "❓";
|
|
307
|
+
readonly fresh: "🆕";
|
|
308
|
+
readonly taken: "🙋";
|
|
309
|
+
readonly landed: "🎉";
|
|
310
|
+
readonly discarded: "🗑️";
|
|
311
|
+
readonly approved: "👍";
|
|
312
|
+
readonly changes: "📝";
|
|
313
|
+
readonly info: "ℹ️";
|
|
314
|
+
};
|
|
315
|
+
/** The sound is a property of the icon, and of nothing else. */
|
|
316
|
+
export declare const LOUD: ReadonlySet<string>;
|
|
317
|
+
export declare const PR_ICON: Record<Extract<NotifyEvent, {
|
|
318
|
+
type: 'pr';
|
|
319
|
+
}>['action'], string>;
|
|
320
|
+
export declare const ISSUE_ICON: Record<Extract<NotifyEvent, {
|
|
321
|
+
type: 'issue';
|
|
322
|
+
}>['action'], string>;
|
|
323
|
+
/** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
|
|
324
|
+
export declare const iconFor: (e: NotifyEvent) => string;
|
|
220
325
|
export declare const severity: (e: NotifyEvent) => "info" | "error";
|
|
221
326
|
export {};
|
package/dist/events.js
CHANGED
|
@@ -10,19 +10,82 @@
|
|
|
10
10
|
* Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
|
|
11
11
|
*/
|
|
12
12
|
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
|
|
15
|
+
*
|
|
16
|
+
* 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
|
|
17
|
+
* ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
|
|
18
|
+
* выглядели одинаково, а у задачи три разных беды — fail, disabled,
|
|
19
|
+
* silent — были одним и тем же красным кругом.
|
|
20
|
+
* 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
|
|
21
|
+
* выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
|
|
22
|
+
* и у PR, и у файла.
|
|
23
|
+
*
|
|
24
|
+
* И третий, который держит первые два честными: у значка фиксированный звук.
|
|
25
|
+
* Не у события, не у статуса — у значка. Пока звук выводился отдельным
|
|
26
|
+
* правилом, `🔴 PR: changes_requested` приходила беззвучно.
|
|
27
|
+
*/
|
|
28
|
+
export const ICON = {
|
|
29
|
+
ok: '✅', // passed, closed, done
|
|
30
|
+
red: '🔴', // broken
|
|
31
|
+
alarm: '🚨', // burning right now
|
|
32
|
+
off: '🚫', // switched off — it will not run until someone turns it back on
|
|
33
|
+
unknown: '❓', // did not report: alive or dead is unknown
|
|
34
|
+
fresh: '🆕', // something new appeared
|
|
35
|
+
taken: '🙋', // someone took it
|
|
36
|
+
landed: '🎉', // merged — the work is in
|
|
37
|
+
discarded: '🗑️', // closed without reaching the result
|
|
38
|
+
approved: '👍', // a human approved it
|
|
39
|
+
changes: '📝', // a human wants edits — not a failure, and not loud
|
|
40
|
+
info: 'ℹ️' // a summary, for information
|
|
41
|
+
};
|
|
42
|
+
/** The sound is a property of the icon, and of nothing else. */
|
|
43
|
+
export const LOUD = new Set([ICON.red, ICON.alarm, ICON.off, ICON.unknown]);
|
|
44
|
+
export const PR_ICON = {
|
|
45
|
+
opened: ICON.fresh,
|
|
46
|
+
approved: ICON.approved,
|
|
47
|
+
changes_requested: ICON.changes,
|
|
48
|
+
merged: ICON.landed,
|
|
49
|
+
closed: ICON.discarded
|
|
50
|
+
};
|
|
51
|
+
export const ISSUE_ICON = {
|
|
52
|
+
opened: ICON.fresh,
|
|
53
|
+
assigned: ICON.taken,
|
|
54
|
+
closed: ICON.ok
|
|
55
|
+
};
|
|
56
|
+
const JOB_ICON = {
|
|
57
|
+
ok: ICON.ok,
|
|
58
|
+
fail: ICON.red,
|
|
59
|
+
disabled: ICON.off,
|
|
60
|
+
silent: ICON.unknown
|
|
61
|
+
};
|
|
62
|
+
/** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
|
|
63
|
+
export const iconFor = (e) => {
|
|
64
|
+
switch (e.type) {
|
|
65
|
+
case 'deploy':
|
|
66
|
+
case 'ci':
|
|
67
|
+
return e.status === 'ok' ? ICON.ok : ICON.red;
|
|
68
|
+
case 'job':
|
|
69
|
+
return JOB_ICON[e.status];
|
|
70
|
+
case 'session':
|
|
71
|
+
return e.status === 'ok' ? ICON.ok : ICON.alarm;
|
|
72
|
+
case 'incident':
|
|
73
|
+
return ICON.alarm;
|
|
74
|
+
case 'heartbeat_miss':
|
|
75
|
+
return e.recovered ? ICON.ok : ICON.unknown;
|
|
76
|
+
case 'pr':
|
|
77
|
+
return PR_ICON[e.action];
|
|
78
|
+
case 'issue':
|
|
79
|
+
return ISSUE_ICON[e.action];
|
|
80
|
+
case 'report':
|
|
81
|
+
return ICON.info;
|
|
26
82
|
}
|
|
27
|
-
|
|
83
|
+
};
|
|
84
|
+
export const severity = (e) => {
|
|
85
|
+
// ONE law: the icon decides the sound. There is no second list of "which
|
|
86
|
+
// events are bad" to keep in sync with the icons — keeping two lists is how
|
|
87
|
+
// `🔴 PR: changes_requested` ended up arriving MUTED, the only red card in
|
|
88
|
+
// the package that did not ring, because severity() looked at `status` and a
|
|
89
|
+
// pull request has an `action`.
|
|
90
|
+
return LOUD.has(iconFor(e)) ? 'error' : 'info';
|
|
28
91
|
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export { severity } from "./events.js";
|
|
2
2
|
export { notify, sendReport } from "./send.js";
|
|
3
|
+
// `render` наружу — чтобы отправитель мог положить на диск РОВНО ту карточку,
|
|
4
|
+
// которая уехала, а не свою вторую версию текста. Сборка копии вручную уже
|
|
5
|
+
// расходилась с отправленным.
|
|
6
|
+
export { render } from "./render.js";
|
package/dist/render.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
|
|
20
20
|
* после каждой строки.
|
|
21
21
|
*/
|
|
22
|
-
import type
|
|
22
|
+
import { type NotifyEvent } from './events.ts';
|
|
23
23
|
/** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
|
|
24
24
|
export declare const esc: (v: unknown) => string;
|
|
25
25
|
/**
|
|
@@ -36,6 +36,7 @@ export declare const esc: (v: unknown) => string;
|
|
|
36
36
|
* повторяем — сообщение исчезало совсем.
|
|
37
37
|
*/
|
|
38
38
|
export declare const clampMessage: (text: string, limit?: number) => string;
|
|
39
|
+
export declare const slug: (raw: string) => string;
|
|
39
40
|
/**
|
|
40
41
|
* Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
|
|
41
42
|
* задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
|
|
@@ -45,6 +46,14 @@ export declare const clampMessage: (text: string, limit?: number) => string;
|
|
|
45
46
|
* заголовок и есть единственное стабильное поле).
|
|
46
47
|
*/
|
|
47
48
|
export declare const eventKey: (e: NotifyEvent) => string;
|
|
49
|
+
/**
|
|
50
|
+
* Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
|
|
51
|
+
* и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
|
|
52
|
+
* текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
|
|
53
|
+
* висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
|
|
54
|
+
* теги переехали первой строкой.
|
|
55
|
+
*/
|
|
56
|
+
export declare const reportTags: (key: string) => string;
|
|
48
57
|
/**
|
|
49
58
|
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
50
59
|
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
package/dist/render.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Один рендерер на тип события, все по одному каркасу — утверждён
|
|
3
|
+
* владельцем 20.08.2026 после ~15 живых раундов в тестовом форуме:
|
|
4
|
+
*
|
|
5
|
+
* #тип #экземпляр
|
|
6
|
+
* значок <b>Тип:</b> действие
|
|
7
|
+
*
|
|
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
|
+
* после каждой строки.
|
|
21
|
+
*/
|
|
22
|
+
import { iconFor } from "./events.js";
|
|
1
23
|
/** Первая буква — заглавная, остальное как есть (ga4/GitHub остаются собой). */
|
|
2
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Ярлык с большой буквы — но НЕ у имени, которое пишется со строчной нарочно:
|
|
26
|
+
* `iOS` превращалось в `IOS`. Признак — вторая буква заглавная.
|
|
27
|
+
*/
|
|
28
|
+
const cap = (s) => {
|
|
29
|
+
if (s.length === 0 || /^[a-z][A-Z]/.test(s)) {
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
33
|
+
};
|
|
3
34
|
/** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
|
|
4
35
|
export const esc = (v) => String(v ?? '')
|
|
5
36
|
.replace(/&/g, '&')
|
|
@@ -118,6 +149,23 @@ const fieldLink = (label, url, text) => {
|
|
|
118
149
|
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
119
150
|
/** Моноширинное поле — путь/команда для копирования, не ссылка. */
|
|
120
151
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
152
|
+
/**
|
|
153
|
+
* Строка, которая просит что-то ОТ ВЛАДЕЛЬЦА, а не сообщает факт. Она уже
|
|
154
|
+
* стояла последней и через пустую строку, и всё равно читалась как рядовое
|
|
155
|
+
* поле среди пяти других. Маркер `▶` — единственное отличие: заголовок группы
|
|
156
|
+
* здесь был бы третьей строкой разметки на карточку из шести (25.08.2026, два
|
|
157
|
+
* ревью против группировки), а маркер не тратит ни одной.
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* Действие: что сделать и чем. Без объяснения команда НЕ печатается вовсе —
|
|
161
|
+
* владелец на голую `rm` в карточке: «я ж не знаю, что делаю». Молча уронить
|
|
162
|
+
* строку лучше, чем показать ему команду, которую он не может прочитать;
|
|
163
|
+
* отправителя при этом ловит тест каталога, а не тишина в чате.
|
|
164
|
+
*/
|
|
165
|
+
const fieldRun = (value, why) => {
|
|
166
|
+
const explain = field('To do', why);
|
|
167
|
+
return value && explain !== null ? [explain, `▶ <code>${esc(value)}</code>`] : [];
|
|
168
|
+
};
|
|
121
169
|
/** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
|
|
122
170
|
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
123
171
|
/** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
|
|
@@ -140,6 +188,14 @@ const note = (text) => {
|
|
|
140
188
|
const body = esc(text);
|
|
141
189
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
142
190
|
};
|
|
191
|
+
/**
|
|
192
|
+
* Цитата с подписью. Голая цитата читается как продолжение поля над ней:
|
|
193
|
+
* владелец спросил про строку, которой открыта сессия, «что значит этот текст,
|
|
194
|
+
* откуда он берётся» — и был прав, в карточке это нигде не сказано. Подпись
|
|
195
|
+
* стоит отдельной строкой, потому что сам текст в поле не помещается: поле
|
|
196
|
+
* держит одну строку и обрезает.
|
|
197
|
+
*/
|
|
198
|
+
const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
|
|
143
199
|
const join = (parts) => parts.filter((p) => p !== null).join('\n');
|
|
144
200
|
/** Плоский список позиций (без ярлыков) — job/report без групп. */
|
|
145
201
|
const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
|
|
@@ -163,19 +219,23 @@ const renderGroup = (g) => [
|
|
|
163
219
|
*/
|
|
164
220
|
const titleField = (title) => field('Title', title);
|
|
165
221
|
const bodyQuote = (body) => body ? note(body) : null;
|
|
166
|
-
// Значок
|
|
167
|
-
// закреплённая легенда в форумах обещает это владельцу как факт, не как
|
|
168
|
-
// приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
|
|
169
|
-
const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
|
|
222
|
+
// Значок и его закон живут в events.ts: от него зависит и звук.
|
|
170
223
|
/** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
|
|
171
224
|
// `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
|
|
172
225
|
// JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
|
|
173
226
|
// прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
|
|
174
|
-
|
|
227
|
+
// A link belongs on the NAME of the thing it opens, never on a separate row
|
|
228
|
+
// whose only text is the verb `open`. The owner read `Details: open` under a
|
|
229
|
+
// report and asked what "open" was — the answer is the report itself, which was
|
|
230
|
+
// sitting three lines above as dead text. So line 2 takes an optional URL and
|
|
231
|
+
// the action text becomes the link: `Report: <a>Analytics for 12.08</a>`.
|
|
232
|
+
const typeLine = (icon, type, action, url) => {
|
|
175
233
|
// `field` возвращает null на пустом значении, а интерполяция null в шаблон
|
|
176
234
|
// печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
|
|
177
235
|
// достижимо через `--json` и прямой вызов на JS, где типов нет.
|
|
178
|
-
|
|
236
|
+
// `action || 'open'` in the linked case: an empty title must not swallow the
|
|
237
|
+
// link, which would be the one thing the card cannot afford to lose.
|
|
238
|
+
const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
|
|
179
239
|
return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
|
|
180
240
|
};
|
|
181
241
|
// `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
|
|
@@ -183,23 +243,45 @@ const typeLine = (icon, type, action) => {
|
|
|
183
243
|
// только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
|
|
184
244
|
// на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
|
|
185
245
|
// однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
|
|
246
|
+
/**
|
|
247
|
+
* What to call the thing that ran. The workflow's own name first — it is the
|
|
248
|
+
* only text here that identifies THIS run. Then the caller's own word for the
|
|
249
|
+
* mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
|
|
250
|
+
* link exists: losing the link to the logs on a red card is the one loss this
|
|
251
|
+
* format cannot afford, and a row that says nothing is still better than a
|
|
252
|
+
* card with nowhere to click. No live sender reaches that last resort — the
|
|
253
|
+
* GitHub Action always fills the workflow name, and the hand-run scripts send
|
|
254
|
+
* no run link at all.
|
|
255
|
+
*/
|
|
256
|
+
const mechanism = (workflowName, via, runUrl) => workflowName ?? via ?? (runUrl ? 'the run' : undefined);
|
|
257
|
+
// The name of what ran sits WITH the type line, not eight lines below it.
|
|
258
|
+
// `Deploy: fail` and `by what means it ran` answer one question, and the owner
|
|
259
|
+
// read the two rows as unrelated things. It used to be one fact split in two:
|
|
260
|
+
// `Via: GitHub Actions` in the middle of the card and a trailing
|
|
261
|
+
// `Workflow: <run>` in the actions block. On one-q that trailing row rendered
|
|
262
|
+
// `Workflow: Deploy` — the link text repeating the word on line 2 and naming
|
|
263
|
+
// nothing.
|
|
264
|
+
//
|
|
265
|
+
// The link text is the workflow's OWN name, never the platform: `GitHub
|
|
266
|
+
// Actions` is identical on every card in every repository, so clicking it told
|
|
267
|
+
// the owner nothing about where he was going. `manual, from the Mac` stays
|
|
268
|
+
// unlinked, because a hand deploy has no run to open.
|
|
186
269
|
const renderDeploy = (e) => {
|
|
187
|
-
const icon = e
|
|
270
|
+
const icon = iconFor(e);
|
|
271
|
+
const runUrl = e.workflowUrl ?? e.url;
|
|
188
272
|
return join([
|
|
189
273
|
typeLine(icon, 'Deploy', e.status),
|
|
274
|
+
fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
|
|
190
275
|
'',
|
|
191
276
|
fieldLink('Commit', e.commitUrl, e.commit),
|
|
192
277
|
titleField(e.commitTitle),
|
|
193
278
|
bodyQuote(e.commitBody),
|
|
194
|
-
field('Via', e.via),
|
|
195
279
|
field('Target', e.target),
|
|
196
|
-
field('Reason', e.note)
|
|
197
|
-
e.workflowUrl ?? e.url ? '' : null,
|
|
198
|
-
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
280
|
+
field('Reason', e.note)
|
|
199
281
|
]);
|
|
200
282
|
};
|
|
201
283
|
const renderJob = (e) => {
|
|
202
|
-
const icon = e
|
|
284
|
+
const icon = iconFor(e);
|
|
203
285
|
const hasItems = (e.items ?? []).length > 0;
|
|
204
286
|
const disabledList = hasItems && e.status === 'disabled';
|
|
205
287
|
return join([
|
|
@@ -210,8 +292,15 @@ const renderJob = (e) => {
|
|
|
210
292
|
// repeating the label of the line right above it reads as a mistake.
|
|
211
293
|
// Until now the name was dropped entirely — every caller passed it and the
|
|
212
294
|
// owner only ever saw it as the small grey instance tag.
|
|
213
|
-
|
|
295
|
+
// The run link rides on the task's own name. It used to sit at the bottom
|
|
296
|
+
// as `Workflow: open` — every job caller passes a URL and none passes a
|
|
297
|
+
// workflow name, so that row was the bare verb the owner objected to.
|
|
298
|
+
fieldLink('Task', e.workflowUrl ?? e.url, e.job),
|
|
214
299
|
field('Reason', e.note),
|
|
300
|
+
field('Expected', e.expected),
|
|
301
|
+
// `Last run` when the task is alive, `Last seen` when it is not: the same
|
|
302
|
+
// timestamp answers two different questions.
|
|
303
|
+
field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
|
|
215
304
|
...(e.stats ?? []).map(([label, value]) => field(label, value)),
|
|
216
305
|
hasItems ? '' : null,
|
|
217
306
|
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
@@ -219,68 +308,65 @@ const renderJob = (e) => {
|
|
|
219
308
|
// "Disabled workflows".
|
|
220
309
|
disabledList ? group('Disabled workflows') : null,
|
|
221
310
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
222
|
-
e.
|
|
223
|
-
|
|
311
|
+
e.command || e.logs ? '' : null,
|
|
312
|
+
fieldCode('Log', e.logs),
|
|
313
|
+
...fieldRun(e.command, e.commandNote),
|
|
314
|
+
// Kept only when the caller actually names the workflow — a named row is a
|
|
315
|
+
// second, different destination; an unnamed one repeats the Task link.
|
|
316
|
+
e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
|
|
317
|
+
e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
|
|
224
318
|
]);
|
|
225
319
|
};
|
|
226
320
|
const renderReport = (e) => {
|
|
227
321
|
if (e.groups && e.groups.length > 0) {
|
|
228
322
|
const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
|
|
323
|
+
// `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
|
|
324
|
+
// ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
|
|
325
|
+
// переводе утреннего отчёта PlayHub на типизированное событие.
|
|
326
|
+
const numbers = (e.lines ?? []).map(([label, value]) => field(label, value));
|
|
229
327
|
return join([
|
|
230
|
-
typeLine(
|
|
328
|
+
typeLine(iconFor(e), 'Report', e.title, e.url),
|
|
231
329
|
'',
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
330
|
+
field('Period', e.period),
|
|
331
|
+
...numbers,
|
|
332
|
+
body.length > 0 ? '' : null,
|
|
333
|
+
...body
|
|
235
334
|
]);
|
|
236
335
|
}
|
|
237
336
|
const items = bullets(e.items, false);
|
|
238
337
|
return join([
|
|
239
|
-
|
|
338
|
+
// Both analytics jobs send a link to the day's snapshot in docs/. It used to
|
|
339
|
+
// hang off a trailing `Details: open` row; now it is the report's own name.
|
|
340
|
+
typeLine(iconFor(e), 'Report', e.title, e.url),
|
|
240
341
|
'',
|
|
342
|
+
// The period used to ride on the type line after a middot, which made the
|
|
343
|
+
// clickable name longer than the name and put two facts on one line.
|
|
344
|
+
field('Period', e.period),
|
|
241
345
|
...(e.lines ?? []).map(([label, value]) => field(label, value)),
|
|
242
346
|
items.length > 0 ? '' : null,
|
|
243
|
-
...items
|
|
244
|
-
// Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
|
|
245
|
-
// и дневной отчёт приходил без единственного способа посмотреть подробности.
|
|
246
|
-
e.url ? '' : null,
|
|
247
|
-
fieldAction('Details', e.url, undefined)
|
|
347
|
+
...items
|
|
248
348
|
]);
|
|
249
349
|
};
|
|
350
|
+
// Same law as the deploy card, one row up: what ran is named beside the type
|
|
351
|
+
// line and carries the link to its run. The label is `Check` and not `Via`
|
|
352
|
+
// because here the name answers WHICH gate spoke — `nightly`, `Quality` —
|
|
353
|
+
// while on a deploy it answers by what means the code was shipped.
|
|
250
354
|
const renderCi = (e) => {
|
|
251
|
-
const icon = e
|
|
355
|
+
const icon = iconFor(e);
|
|
356
|
+
const runUrl = e.workflowUrl ?? e.url;
|
|
252
357
|
return join([
|
|
253
358
|
typeLine(icon, 'CI', e.status),
|
|
359
|
+
fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
|
|
254
360
|
'',
|
|
255
361
|
fieldLink('Commit', e.commitUrl, e.commit),
|
|
256
362
|
titleField(e.commitTitle),
|
|
257
363
|
bodyQuote(e.commitBody),
|
|
258
364
|
field('Actor', e.actor),
|
|
259
|
-
field('Reason', e.note)
|
|
260
|
-
e.workflowUrl ?? e.url ? '' : null,
|
|
261
|
-
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
365
|
+
field('Reason', e.note)
|
|
262
366
|
]);
|
|
263
367
|
};
|
|
264
|
-
// PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
|
|
265
|
-
// `merged`/`approved` = успех, `changes_requested` = требует внимания,
|
|
266
|
-
// остальное = к сведению. Слово действия само по себе уже говорит, что
|
|
267
|
-
// произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
|
|
268
|
-
const PR_ICON = {
|
|
269
|
-
opened: ICON.info,
|
|
270
|
-
ready_for_review: ICON.info,
|
|
271
|
-
review_requested: ICON.info,
|
|
272
|
-
approved: ICON.ok,
|
|
273
|
-
changes_requested: ICON.red,
|
|
274
|
-
merged: ICON.ok,
|
|
275
|
-
closed: ICON.info
|
|
276
|
-
};
|
|
277
|
-
const ISSUE_ICON = {
|
|
278
|
-
opened: ICON.info,
|
|
279
|
-
assigned: ICON.info,
|
|
280
|
-
closed: ICON.ok
|
|
281
|
-
};
|
|
282
368
|
const renderPr = (e) => join([
|
|
283
|
-
typeLine(
|
|
369
|
+
typeLine(iconFor(e), 'PR', e.action),
|
|
284
370
|
'',
|
|
285
371
|
// Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
|
|
286
372
|
// такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
|
|
@@ -294,7 +380,7 @@ const renderPr = (e) => join([
|
|
|
294
380
|
field('Reviewer', e.reviewer)
|
|
295
381
|
]);
|
|
296
382
|
const renderIssue = (e) => join([
|
|
297
|
-
typeLine(
|
|
383
|
+
typeLine(iconFor(e), 'Issue', e.action),
|
|
298
384
|
'',
|
|
299
385
|
fieldLink('Number', e.url, `#${e.number}`),
|
|
300
386
|
titleField(e.title),
|
|
@@ -303,7 +389,7 @@ const renderIssue = (e) => join([
|
|
|
303
389
|
field('Assignee', e.assignee)
|
|
304
390
|
]);
|
|
305
391
|
const renderIncident = (e) => join([
|
|
306
|
-
typeLine(
|
|
392
|
+
typeLine(iconFor(e), 'Incident', 'open'),
|
|
307
393
|
'',
|
|
308
394
|
// `detail` is a diagnosis of several lines (vault greps three of them plus a
|
|
309
395
|
// log path). It used to go through `field`, which keeps only the first line,
|
|
@@ -311,18 +397,33 @@ const renderIncident = (e) => join([
|
|
|
311
397
|
// commit now: short label, full text quoted under it.
|
|
312
398
|
// Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
|
|
313
399
|
// как у коммита и задачи, и называться в одной карточке он должен так же.
|
|
314
|
-
|
|
400
|
+
// Same rule as the report: the link rides on the incident's own title
|
|
401
|
+
// rather than on a trailing row whose only text is the word `open`.
|
|
402
|
+
fieldLink('Title', e.url, e.title),
|
|
315
403
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
316
|
-
e.logs
|
|
317
|
-
fieldCode('Logs', e.logs)
|
|
318
|
-
fieldAction('Workflow', e.url, undefined)
|
|
404
|
+
e.logs ? '' : null,
|
|
405
|
+
fieldCode('Logs', e.logs)
|
|
319
406
|
]);
|
|
320
407
|
// Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
|
|
321
408
|
// reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
|
|
322
409
|
// свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
|
|
323
410
|
// формат. Отдельного формата больше нет.
|
|
411
|
+
// A session in trouble. Same law as every other card: identifier first, then
|
|
412
|
+
// the facts as fields, then his own words as a quote — never as a field, which
|
|
413
|
+
// keeps one line and clipped the name of the very session the card is about.
|
|
414
|
+
const renderSession = (e) => join([
|
|
415
|
+
typeLine(iconFor(e), 'Session', e.action),
|
|
416
|
+
'',
|
|
417
|
+
field('Id', e.id),
|
|
418
|
+
field('Project', e.workdir),
|
|
419
|
+
field('Reason', e.reason),
|
|
420
|
+
e.opened ? '' : null,
|
|
421
|
+
quoted('Opened with', e.opened),
|
|
422
|
+
e.command ? '' : null,
|
|
423
|
+
...fieldRun(e.command, e.commandNote)
|
|
424
|
+
]);
|
|
324
425
|
const renderHeartbeatMiss = (e) => {
|
|
325
|
-
const icon = e
|
|
426
|
+
const icon = iconFor(e);
|
|
326
427
|
const action = e.recovered ? 'ok' : 'miss';
|
|
327
428
|
return join([
|
|
328
429
|
typeLine(icon, 'Heartbeat', action),
|
|
@@ -333,16 +434,6 @@ const renderHeartbeatMiss = (e) => {
|
|
|
333
434
|
field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
|
|
334
435
|
]);
|
|
335
436
|
};
|
|
336
|
-
// Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
|
|
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
|
-
]);
|
|
346
437
|
const RENDERERS = {
|
|
347
438
|
deploy: renderDeploy,
|
|
348
439
|
job: renderJob,
|
|
@@ -351,8 +442,8 @@ const RENDERERS = {
|
|
|
351
442
|
pr: renderPr,
|
|
352
443
|
issue: renderIssue,
|
|
353
444
|
incident: renderIncident,
|
|
354
|
-
|
|
355
|
-
|
|
445
|
+
session: renderSession,
|
|
446
|
+
heartbeat_miss: renderHeartbeatMiss
|
|
356
447
|
};
|
|
357
448
|
// Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
|
|
358
449
|
// (решение владельца 20.08.2026): раньше это были два разных представления
|
|
@@ -361,7 +452,7 @@ const RENDERERS = {
|
|
|
361
452
|
// разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
|
|
362
453
|
// только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
|
|
363
454
|
// «показать всю историю этого экземпляра», которым владелец пользуется вживую.
|
|
364
|
-
const slug = (raw) => raw
|
|
455
|
+
export const slug = (raw) => raw
|
|
365
456
|
.toLowerCase()
|
|
366
457
|
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
367
458
|
.replace(/^_+|_+$/g, '')
|
|
@@ -377,8 +468,8 @@ const TYPE_TAG = {
|
|
|
377
468
|
pr: 'pr',
|
|
378
469
|
issue: 'issue',
|
|
379
470
|
incident: 'incident',
|
|
380
|
-
|
|
381
|
-
|
|
471
|
+
session: 'session',
|
|
472
|
+
heartbeat_miss: 'heartbeat'
|
|
382
473
|
};
|
|
383
474
|
/**
|
|
384
475
|
* Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
|
|
@@ -400,8 +491,11 @@ export const eventKey = (e) => {
|
|
|
400
491
|
return slug(e.job);
|
|
401
492
|
case 'report':
|
|
402
493
|
case 'incident':
|
|
403
|
-
case 'file':
|
|
404
494
|
return slug(e.title);
|
|
495
|
+
// NOT the session id: an id is unique per session, so the tag would be
|
|
496
|
+
// new every time and nothing could ever be paired with anything.
|
|
497
|
+
case 'session':
|
|
498
|
+
return slug(e.action);
|
|
405
499
|
case 'pr':
|
|
406
500
|
return `p${e.number}`;
|
|
407
501
|
case 'issue':
|
|
@@ -411,6 +505,14 @@ export const eventKey = (e) => {
|
|
|
411
505
|
return e.key ? slug(e.key) : fallback();
|
|
412
506
|
};
|
|
413
507
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
|
|
508
|
+
/**
|
|
509
|
+
* Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
|
|
510
|
+
* и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
|
|
511
|
+
* текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
|
|
512
|
+
* висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
|
|
513
|
+
* теги переехали первой строкой.
|
|
514
|
+
*/
|
|
515
|
+
export const reportTags = (key) => `#report #${esc(slug(key))}`;
|
|
414
516
|
/**
|
|
415
517
|
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
416
518
|
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
|
@@ -430,6 +532,7 @@ export const render = (e) => {
|
|
|
430
532
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
|
431
533
|
// многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
|
|
432
534
|
// есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
|
|
433
|
-
|
|
535
|
+
// Карточка с вложением — это подпись, поэтому бюджет выбирается по `path`.
|
|
536
|
+
const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
|
|
434
537
|
return `${tags}\n${clampMessage(renderer(e), budget)}`;
|
|
435
538
|
};
|
package/dist/routes.js
CHANGED
|
@@ -34,7 +34,7 @@ export const targets = (e) => {
|
|
|
34
34
|
// Возвращаем пустой список, а не падаем: уведомление не имеет права уронить
|
|
35
35
|
// вызвавший его деплой или крон (в bash с `set -e` падение было бы фатальным).
|
|
36
36
|
if (!forum) {
|
|
37
|
-
console.error(`[notify]
|
|
37
|
+
console.error(`[notify] unknown project "${e.project}" — known: ${Object.keys(ROUTES).join(', ')}`);
|
|
38
38
|
return [];
|
|
39
39
|
}
|
|
40
40
|
return [{ chat: forum.chat, thread: forum.ops, silent: severity(e) === 'info' }];
|
package/dist/send.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { basename } from 'node:path';
|
|
18
|
-
import { clampMessage, render } from "./render.js";
|
|
18
|
+
import { clampMessage, render, reportTags } from "./render.js";
|
|
19
19
|
import { ROUTES, targets } from "./routes.js";
|
|
20
20
|
const log = (msg) => {
|
|
21
21
|
// stderr, не stdout — stdout зарезервирован под возможный машинный вывод CLI.
|
|
@@ -279,7 +279,8 @@ export const notify = async (e) => {
|
|
|
279
279
|
await reportLostProject(e.project, String(e.type));
|
|
280
280
|
return 'skipped';
|
|
281
281
|
}
|
|
282
|
-
|
|
282
|
+
// A card with a file becomes the caption of that file — one card, not two.
|
|
283
|
+
if (e.path) {
|
|
283
284
|
return sendFile(e);
|
|
284
285
|
}
|
|
285
286
|
return deliver(targets(e), render(e));
|
|
@@ -310,7 +311,8 @@ export const sendReport = async (project, html, key) => {
|
|
|
310
311
|
}
|
|
311
312
|
const forum = ROUTES[project];
|
|
312
313
|
// Без проекта — как и render.ts: карточка уже лежит в форуме своего
|
|
313
|
-
// проекта, дублировать его в теге незачем.
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
// проекта, дублировать его в теге незачем. Строка тегов ПЕРВАЯ и до обрезки,
|
|
315
|
+
// как у любой другой карточки.
|
|
316
|
+
const tags = reportTags(key ?? 'daily');
|
|
317
|
+
return deliver([{ chat: forum.chat, thread: forum.ops, silent: true }], `${tags}\n${clampMessage(html, Math.max(64, 4000 - tags.length - 1))}`);
|
|
316
318
|
};
|
package/dist/setup.js
CHANGED
|
@@ -23,21 +23,21 @@ const createTopic = async (token, chat, name, color) => {
|
|
|
23
23
|
});
|
|
24
24
|
const body = (await res.json());
|
|
25
25
|
if (!body.ok || !body.result) {
|
|
26
|
-
log(
|
|
26
|
+
log(`could not create "${name}": ${body.description ?? `HTTP ${res.status}`}`);
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
return body.result.message_thread_id;
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
32
32
|
// Сеть/таймаут/нечитаемый ответ — не роняем CLI (его контракт: всегда exit 0).
|
|
33
|
-
log(
|
|
33
|
+
log(`could not create "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
37
|
export const setupTopic = async (chatId, projectKey) => {
|
|
38
38
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
39
39
|
if (!token) {
|
|
40
|
-
log('
|
|
40
|
+
log('no OPS_BOT_TOKEN — cannot create the tabs');
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
|
|
@@ -45,16 +45,16 @@ export const setupTopic = async (chatId, projectKey) => {
|
|
|
45
45
|
// Частичный успех: если создался только Ops — печатаем его, иначе повторный
|
|
46
46
|
// запуск создал бы ДРУГУЮ тему Ops, а старый id потерялся бы.
|
|
47
47
|
if (ops === null) {
|
|
48
|
-
log('Ops
|
|
48
|
+
log('Ops was not created — check: is the bot a group admin with "Manage topics", and are topics on?');
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
if (dev === null) {
|
|
52
|
-
log(`Ops
|
|
53
|
-
log('
|
|
54
|
-
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev:
|
|
52
|
+
log(`Ops created (id=${ops}), Dev was not — add Dev by hand or run again, and keep ops=${ops}`);
|
|
53
|
+
log('add to src/routes.ts (fill dev in afterwards):');
|
|
54
|
+
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: <fill in> },`);
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
log(
|
|
58
|
-
log('
|
|
57
|
+
log(`tabs created: Ops=${ops}, Dev=${dev}`);
|
|
58
|
+
log('add to src/routes.ts:');
|
|
59
59
|
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: ${dev} },`);
|
|
60
60
|
};
|
package/package.json
CHANGED