@mikitasazan/notify 1.3.0 → 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.
@@ -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', 'recovered']);
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(`лишний аргумент без флага: «${arg}»`);
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(`флаг --${key} без значения`);
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: неизвестное действие PR «${raw ?? ''}» (${Object.keys(PR_ALIASES).join(', ')})`);
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: неизвестное действие задачи «${raw ?? ''}» (${Object.keys(ISSUE_ALIASES).join(', ')})`);
132
+ parseErrors.push(`--action: unknown issue action "${raw ?? ''}" (${Object.keys(ISSUE_ALIASES).join(', ')})`);
126
133
  return 'opened';
127
134
  }
128
135
  return hit;
@@ -157,7 +164,9 @@ if (flags.has('json')) {
157
164
  event = { ...payload, type: command };
158
165
  }
159
166
  catch (err) {
160
- log(`не удалось разобрать --json со stdin: ${err instanceof Error ? err.message : String(err)}`);
167
+ // Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
168
+ // разбор JSON означал бы зелёный крон без дневного отчёта.
169
+ parseErrors.push(`--json from stdin did not parse: ${err instanceof Error ? err.message : String(err)}`);
161
170
  }
162
171
  }
163
172
  else {
@@ -215,6 +224,7 @@ else {
215
224
  commitTitle: one('commit-title'),
216
225
  commitBody: one('commit-body'),
217
226
  actor: one('actor'),
227
+ note: one('note'),
218
228
  workflowUrl: one('workflow-url'),
219
229
  workflowName: one('workflow-name'),
220
230
  url: one('url')
@@ -227,6 +237,7 @@ else {
227
237
  action: prAction(one('action')),
228
238
  number: num('number'),
229
239
  title: one('title') ?? '(без заголовка)',
240
+ body: one('body'),
230
241
  author: one('author'),
231
242
  reviewer: one('reviewer'),
232
243
  url: one('url')
@@ -282,7 +293,10 @@ else {
282
293
  break;
283
294
  }
284
295
  default:
285
- log(`неизвестный тип события: ${command ?? '(не указан)'}`);
296
+ // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
297
+ // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
298
+ // нулём, ничего не отправив и ничего об этом не сказав.
299
+ parseErrors.push(`unknown event type: ${command ?? '(none given)'}`);
286
300
  }
287
301
  }
288
302
  // --key применим к любому типу — одна точка вместо строки в каждом case
@@ -297,7 +311,26 @@ if (parseErrors.length > 0) {
297
311
  for (const err of parseErrors) {
298
312
  log(err);
299
313
  }
300
- log('событие не отправлено исправь команду');
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`);
301
334
  process.exit(0);
302
335
  }
303
336
  if (event) {
@@ -308,7 +341,9 @@ if (event) {
308
341
  log(await notify(event));
309
342
  }
310
343
  catch (err) {
311
- log(`не отправлено: ${err instanceof Error ? err.message : String(err)}`);
344
+ // Слово `failed` контракт, тот же, что у ошибки разбора выше. Без него
345
+ // исключение при отправке читалось сторожами как «ничего не случилось».
346
+ log(`failed: ${err instanceof Error ? err.message : String(err)}`);
312
347
  }
313
348
  }
314
349
  process.exit(0);
package/dist/events.d.ts CHANGED
@@ -87,6 +87,11 @@ export type NotifyEvent = Keyed & (
87
87
  workflowUrl?: string;
88
88
  /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
89
89
  workflowName?: string;
90
+ /**
91
+ * Запасное имя для ссылки на прогон: половина отправителей шлёт её как
92
+ * `--url`. Рендер берёт `workflowUrl ?? url`, так что оба имени работают.
93
+ * В новых вызовах предпочитай `workflowUrl` — оно говорит, куда ведёт.
94
+ */
90
95
  url?: string;
91
96
  }
92
97
  /** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
@@ -124,11 +129,16 @@ 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;
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;
132
142
  /** Ссылка на прогон (workflow run) — отдельно от `url`, который у CI не используется. */
133
143
  workflowUrl?: string;
134
144
  /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
@@ -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,7 +41,12 @@ export const clampMessage = (text, limit = 4000) => {
41
41
  // blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
42
42
  // прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
43
43
  // цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
44
- const tail = ['b', 'a', 'i', 'code', 'blockquote']
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']
45
50
  .filter((t) => {
46
51
  const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
47
52
  const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
@@ -79,14 +84,23 @@ const fieldLink = (label, url, text) => {
79
84
  if (text === undefined || text === null || text === '') {
80
85
  return null;
81
86
  }
82
- return url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text)}</a>` : field(label, text);
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);
83
93
  };
84
94
  /**
85
95
  * Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
86
96
  * поле — прогону просто некуда вести, показывать голое слово «run» без
87
97
  * ссылки бессмысленнее, чем не показывать строку вовсе.
88
98
  */
89
- const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'run')}</a>` : null;
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;
90
104
  /** Моноширинное поле — путь/команда для копирования, не ссылка. */
91
105
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
92
106
  /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
@@ -120,56 +134,90 @@ const renderGroup = (g) => [
120
134
  ...g.items.map((it, i) => groupItem(it, i, false))
121
135
  ];
122
136
  /**
123
- * Цитата коммита/задачи: заголовок первой строкой (multiline title режется
124
- * до первой строкиsubject не должен тащить в цитату собственное тело
125
- * под-коммита), тело — через пустую строку, если есть.
137
+ * ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок —
138
+ * обычное поле `Title:`, тело цитата, и в цитате больше ничего нет.
139
+ *
140
+ * Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
141
+ * строкой. Владелец нашёл, чем это плохо: заголовок — главное в карточке, то,
142
+ * ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
143
+ * одно от другого можно было только по пустой строке. У PR без тела карточка
144
+ * вырождалась в одинокую серую цитату из одной строки.
145
+ *
146
+ * Заголовок режется до первой строки: многострочный subject коммита не должен
147
+ * затягивать в поле собственное тело.
126
148
  */
127
- const commitQuote = (title, body) => {
128
- if (!title && !body) {
129
- return null;
130
- }
131
- const text = [title ? firstLine(title) : undefined, body].filter(Boolean).join('\n\n');
132
- return note(text);
133
- };
149
+ const titleField = (title) => field('Title', title);
150
+ const bodyQuote = (body) => body ? note(body) : null;
134
151
  // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
135
152
  // закреплённая легенда в форумах обещает это владельцу как факт, не как
136
153
  // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
137
154
  const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
138
155
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
139
- const typeLine = (icon, type, action) => `${icon} ${field(type, action)}`;
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
+ // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
140
171
  const renderDeploy = (e) => {
141
172
  const icon = e.status === 'ok' ? ICON.ok : ICON.red;
142
173
  return join([
143
174
  typeLine(icon, 'Deploy', e.status),
144
175
  '',
145
176
  fieldLink('Commit', e.commitUrl, e.commit),
146
- commitQuote(e.commitTitle, e.commitBody),
177
+ titleField(e.commitTitle),
178
+ bodyQuote(e.commitBody),
147
179
  field('Via', e.via),
148
180
  field('Target', e.target),
149
181
  field('Reason', e.note),
150
- e.workflowUrl ? '' : null,
151
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
182
+ e.workflowUrl ?? e.url ? '' : null,
183
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
152
184
  ]);
153
185
  };
154
186
  const renderJob = (e) => {
155
187
  const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
156
188
  const hasItems = (e.items ?? []).length > 0;
189
+ const disabledList = hasItems && e.status === 'disabled';
157
190
  return join([
158
191
  typeLine(icon, 'Job', e.status),
159
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),
160
199
  field('Reason', e.note),
161
200
  ...(e.stats ?? []).map(([label, value]) => field(label, value)),
162
201
  hasItems ? '' : null,
163
- hasItems ? group('Disabled workflows') : null,
164
- ...(hasItems && e.status === 'disabled' ? bullets(e.items, true) : hasItems ? bullets(e.items, false) : []),
165
- e.workflowUrl ? '' : null,
166
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
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)
167
209
  ]);
168
210
  };
169
211
  const renderReport = (e) => {
170
212
  if (e.groups && e.groups.length > 0) {
171
213
  const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
172
- return join([typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title), '', ...body]);
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
+ ]);
173
221
  }
174
222
  const items = bullets(e.items, false);
175
223
  return join([
@@ -177,7 +225,11 @@ const renderReport = (e) => {
177
225
  '',
178
226
  ...(e.lines ?? []).map(([label, value]) => field(label, value)),
179
227
  items.length > 0 ? '' : null,
180
- ...items
228
+ ...items,
229
+ // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
230
+ // и дневной отчёт приходил без единственного способа посмотреть подробности.
231
+ e.url ? '' : null,
232
+ fieldAction('Details', e.url, undefined)
181
233
  ]);
182
234
  };
183
235
  const renderCi = (e) => {
@@ -186,10 +238,12 @@ const renderCi = (e) => {
186
238
  typeLine(icon, 'CI', e.status),
187
239
  '',
188
240
  fieldLink('Commit', e.commitUrl, e.commit),
189
- commitQuote(e.commitTitle, e.commitBody),
241
+ titleField(e.commitTitle),
242
+ bodyQuote(e.commitBody),
190
243
  field('Actor', e.actor),
191
- e.workflowUrl ? '' : null,
192
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
244
+ field('Reason', e.note),
245
+ e.workflowUrl ?? e.url ? '' : null,
246
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
193
247
  ]);
194
248
  };
195
249
  // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
@@ -213,39 +267,67 @@ const ISSUE_ICON = {
213
267
  const renderPr = (e) => join([
214
268
  typeLine(PR_ICON[e.action], 'PR', e.action),
215
269
  '',
216
- fieldLink('Pr', e.url, `#${e.number}`),
217
- note(e.title),
218
- '',
270
+ // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
271
+ // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
272
+ fieldLink('Number', e.url, `#${e.number}`),
273
+ titleField(e.title),
274
+ bodyQuote(e.body),
275
+ // Без пустой строки перед автором: у задачи её нет, и одно и то же поле
276
+ // не должно стоять по-разному в двух соседних карточках. Пустая строка в
277
+ // этом формате означает «дальше указатель, куда пойти» — автор не он.
219
278
  field('Author', e.author),
220
279
  field('Reviewer', e.reviewer)
221
280
  ]);
222
281
  const renderIssue = (e) => join([
223
282
  typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
224
283
  '',
225
- fieldLink('Issue', e.url, `#${e.number}`),
226
- commitQuote(e.title, e.body),
284
+ fieldLink('Number', e.url, `#${e.number}`),
285
+ titleField(e.title),
286
+ bodyQuote(e.body),
227
287
  field('Author', e.author),
228
288
  field('Assignee', e.assignee)
229
289
  ]);
230
290
  const renderIncident = (e) => join([
231
291
  typeLine(ICON.alarm, 'Incident', 'open'),
232
292
  '',
233
- field('Reason', e.detail ?? e.title),
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,
234
301
  e.logs || e.url ? '' : null,
235
302
  fieldCode('Logs', e.logs),
236
303
  fieldAction('Workflow', e.url, undefined)
237
304
  ]);
305
+ // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
306
+ // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
307
+ // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
308
+ // формат. Отдельного формата больше нет.
238
309
  const renderHeartbeatMiss = (e) => {
239
310
  const icon = e.recovered ? ICON.ok : ICON.red;
240
311
  const action = e.recovered ? 'ok' : 'miss';
241
- const reason = e.note ??
242
- (e.recovered
243
- ? `${e.job} is reporting again${e.lastSeen ? ` — last run ${e.lastSeen}` : ''}`
244
- : `${e.job} — no reports${e.expected ? ` — expected ${e.expected}` : ''}${e.lastSeen ? `, last seen ${e.lastSeen}` : ''}`);
245
- return join([typeLine(icon, 'Heartbeat', action), '', field('Reason', reason)]);
312
+ return join([
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)
319
+ ]);
246
320
  };
247
321
  // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
248
- const renderFile = (e) => join([typeLine(ICON.info, 'File', 'new'), '', field('Reason', e.note ?? e.title)]);
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
+ ]);
249
331
  const RENDERERS = {
250
332
  deploy: renderDeploy,
251
333
  job: renderJob,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",