@mikitasazan/notify 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lint.js CHANGED
@@ -141,9 +141,9 @@ export const lintCard = (html) => {
141
141
  if (/[а-яё]/i.test(strippedRows.join('\n'))) {
142
142
  found.push('Cyrillic outside quoted content — system text is English (rule L)');
143
143
  }
144
- // Rule S (v2.1): a card that reports trouble must say where to verify it —
145
- // a `Check:` command, a `Source:` link, a `To do:` command, or any link at
146
- // all. A `Log:` path alone is not enough: a path cannot be tapped, and a
144
+ // Rule S (v2.1, amended 03.09.2026): a card that reports trouble must say
145
+ // where to verify it — a `Check:` command, a `To do:` command, or any link
146
+ // at all (the link now rides on the name on line 2, not on a `Source:` row). A `Log:` path alone is not enough: a path cannot be tapped, and a
147
147
  // card whose only pointer needs a file manager is a card with no pointer.
148
148
  // `#fail` and `#unknown` both: a silent task has no log, but `config jobs
149
149
  // --log <key>` answers it too.
package/dist/render.d.ts CHANGED
@@ -37,6 +37,28 @@ export declare const esc: (v: unknown) => string;
37
37
  * permanent error and do not retry — the message disappeared for good.
38
38
  */
39
39
  export declare const clampMessage: (text: string, limit?: number, marker?: string) => string;
40
+ /**
41
+ * GitHub Markdown, read in Telegram. A PR or issue body arrives as the author
42
+ * wrote it for GitHub, and Telegram knows none of it: `## Как проверял` stood
43
+ * as two hash signs, a screenshot as `![after 375](https://…png)` in full, the
44
+ * PR template's HTML comment as a paragraph, a table as a fence of bars. On
45
+ * the 49 PR and issue cards of the week of 25.08.2026 that was the "wall of
46
+ * text" the owner read. So the body is translated, not pasted (03.09.2026):
47
+ *
48
+ * - a comment `<!-- … -->` is the template talking to the author — dropped;
49
+ * - `## Heading` → bold line; `**x**` → bold; `` `x` `` → code; a fenced
50
+ * block → `<pre>`;
51
+ * - an image `![alt](https://…)` → a link named by its alt (or `image`);
52
+ * a link `[text](https://…)` → a link; a link to a repo path (no scheme)
53
+ * keeps only its text — the address would not open from a phone anyway;
54
+ * - a table: the `|---|` rule is dropped, a row's cells are joined by ` · `;
55
+ * - `Closes #N` / `Fixes #N` lines are GitHub's own bookkeeping — dropped;
56
+ * - runs of blank lines collapse to one.
57
+ *
58
+ * Everything is escaped FIRST, then the markup is added on the escaped text:
59
+ * the patterns contain no `<>&"`, so nothing the author typed can become a tag.
60
+ */
61
+ export declare const markdownToTelegram: (text: string) => string;
40
62
  export declare const slug: (raw: string) => string;
41
63
  /**
42
64
  * The instance tag: exactly which concrete event this is (branch,
package/dist/render.js CHANGED
@@ -92,7 +92,7 @@ export const clampMessage = (text, limit = 4000, marker = '…') => {
92
92
  // bug (25.08.2026), so now the order comes from the text itself: the last
93
93
  // one opened is the first one closed.
94
94
  const open = [];
95
- const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
95
+ const tagRe = /<(\/?)(b|a|i|u|code|pre|blockquote)[ >]/g;
96
96
  for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
97
97
  if (m[1] === '/') {
98
98
  const at = open.lastIndexOf(m[2]);
@@ -167,6 +167,14 @@ const fieldLink = (label, url, text) => {
167
167
  };
168
168
  /** A monospace field — a path/command to copy, not a link. */
169
169
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
170
+ /**
171
+ * The owner himself, under the two names the senders know him by. A people
172
+ * row that names the reader is not news: on the 49 PR and issue cards of the
173
+ * week of 25.08.2026 `Author:` was him on every one, and the single row that
174
+ * ever said something was `Assignee: Ilja-Prihach`. So a person row is printed
175
+ * only when the person is someone else (03.09.2026).
176
+ */
177
+ const OWNER = { github: 'mikitasazan', telegram: 'chelsnebes' };
170
178
  /**
171
179
  * A person field — Author, Assignee, Reviewer. Every one of them is a
172
180
  * GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
@@ -184,6 +192,9 @@ const fieldPerson = (label, login) => {
184
192
  return null;
185
193
  }
186
194
  const oneLine = firstLine(login);
195
+ if (oneLine.toLowerCase() === OWNER.github) {
196
+ return null;
197
+ }
187
198
  return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
188
199
  };
189
200
  /**
@@ -200,7 +211,11 @@ const fieldTelegram = (label, handle) => {
200
211
  return null;
201
212
  }
202
213
  const oneLine = firstLine(handle);
203
- return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(oneLine.replace(/^@/, ''))}">${esc(oneLine)}</a>`;
214
+ const bare = oneLine.replace(/^@/, '');
215
+ if (bare.toLowerCase() === OWNER.telegram) {
216
+ return null;
217
+ }
218
+ return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(bare)}">${esc(oneLine)}</a>`;
204
219
  };
205
220
  /**
206
221
  * A row that asks something FROM THE OWNER, rather than reports a fact. It
@@ -244,9 +259,20 @@ const groupItem = (it, index, numbered) => {
244
259
  if (!it.facts || it.facts.length === 0) {
245
260
  return head;
246
261
  }
247
- const sub = it.facts.map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
262
+ const sub = it.facts
263
+ .filter(([, value]) => !isZeroStill(value))
264
+ .map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
248
265
  return [head, ...sub].join('\n');
249
266
  };
267
+ /**
268
+ * A zero that did not move says nothing. On game-publisher six of the eight
269
+ * analytics rows read `0 / 0 =` every single day, and the liveness check
270
+ * printed six rows of `0` nine days running (25.08–03.09.2026). The owner:
271
+ * silence is the report. So a row whose value is a bare `0`, or a zero
272
+ * compared with a zero, is not printed — and a group left with no rows loses
273
+ * its heading too. A zero that CHANGED (`0 / 3 ▼3`) still prints: that is news.
274
+ */
275
+ const isZeroStill = (value) => /^\s*0(?:[.,]0+)?\s*%?\s*(?:\/\s*0(?:[.,]0+)?\s*%?\s*=?\s*)?$/.test(String(value));
250
276
  // A long explanation (a note, incident details) — as a quote: in Telegram
251
277
  // that is a bar on the left and a light indent, reading as "details," not as
252
278
  // part of the heading. Longer than ~400 characters and the quote collapses
@@ -257,14 +283,118 @@ const groupItem = (it, index, numbered) => {
257
283
  // paragraph and the old length-only rule let them through (v2.1).
258
284
  const EXPAND_AT = 400;
259
285
  const EXPAND_LINES = 5;
260
- const note = (text) => {
261
- if (!text) {
262
- return null;
263
- }
264
- const body = esc(text);
286
+ /** The quote itself, over text that is ALREADY safe HTML. */
287
+ const quoteHtml = (body) => {
265
288
  const long = body.length > EXPAND_AT || body.split('\n').length > EXPAND_LINES;
266
289
  return long ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
267
290
  };
291
+ const note = (text) => (text ? quoteHtml(esc(text)) : null);
292
+ /**
293
+ * GitHub Markdown, read in Telegram. A PR or issue body arrives as the author
294
+ * wrote it for GitHub, and Telegram knows none of it: `## Как проверял` stood
295
+ * as two hash signs, a screenshot as `![after 375](https://…png)` in full, the
296
+ * PR template's HTML comment as a paragraph, a table as a fence of bars. On
297
+ * the 49 PR and issue cards of the week of 25.08.2026 that was the "wall of
298
+ * text" the owner read. So the body is translated, not pasted (03.09.2026):
299
+ *
300
+ * - a comment `<!-- … -->` is the template talking to the author — dropped;
301
+ * - `## Heading` → bold line; `**x**` → bold; `` `x` `` → code; a fenced
302
+ * block → `<pre>`;
303
+ * - an image `![alt](https://…)` → a link named by its alt (or `image`);
304
+ * a link `[text](https://…)` → a link; a link to a repo path (no scheme)
305
+ * keeps only its text — the address would not open from a phone anyway;
306
+ * - a table: the `|---|` rule is dropped, a row's cells are joined by ` · `;
307
+ * - `Closes #N` / `Fixes #N` lines are GitHub's own bookkeeping — dropped;
308
+ * - runs of blank lines collapse to one.
309
+ *
310
+ * Everything is escaped FIRST, then the markup is added on the escaped text:
311
+ * the patterns contain no `<>&"`, so nothing the author typed can become a tag.
312
+ */
313
+ export const markdownToTelegram = (text) => {
314
+ const stripped = text.replace(/<!--[\s\S]*?-->/g, '');
315
+ const out = [];
316
+ let fence = null;
317
+ for (const raw of stripped.split('\n')) {
318
+ const line = esc(raw);
319
+ if (/^\s*```/.test(line)) {
320
+ if (fence) {
321
+ out.push(`<pre>${fence.join('\n')}</pre>`);
322
+ fence = null;
323
+ }
324
+ else {
325
+ fence = [];
326
+ }
327
+ continue;
328
+ }
329
+ if (fence) {
330
+ fence.push(line);
331
+ continue;
332
+ }
333
+ if (/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line)) {
334
+ continue; // a table's header rule
335
+ }
336
+ if (/^\s*(closes|fixes|resolves)\s+#\d+\s*$/i.test(line)) {
337
+ continue;
338
+ }
339
+ let row = line;
340
+ const heading = row.match(/^\s*#{1,6}\s+(.*?)\s*#*\s*$/);
341
+ if (heading) {
342
+ row = `<b>${heading[1]}</b>`;
343
+ }
344
+ else if (/^\s*\|.*\|\s*$/.test(row)) {
345
+ row = row
346
+ .trim()
347
+ .slice(1, -1)
348
+ .split('|')
349
+ .map((c) => c.trim())
350
+ .filter((c) => c !== '')
351
+ .join(' · ');
352
+ }
353
+ row = row
354
+ .replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_, alt, url) => `<a href="${url}">${alt.trim() || 'image'}</a>`)
355
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>')
356
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
357
+ .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
358
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>');
359
+ out.push(row);
360
+ }
361
+ if (fence) {
362
+ out.push(`<pre>${fence.join('\n')}</pre>`);
363
+ }
364
+ return out
365
+ .join('\n')
366
+ .replace(/\n{3,}/g, '\n\n')
367
+ .trim();
368
+ };
369
+ /**
370
+ * The first section of a Markdown body: everything up to the SECOND `## `
371
+ * heading. On a merged or closed card the body is text he has already read
372
+ * when the thing was opened; the section that says what changed for the user
373
+ * is enough, the rest is one tap away behind the number.
374
+ */
375
+ const firstSection = (text) => {
376
+ const lines = text.replace(/<!--[\s\S]*?-->/g, '').split('\n');
377
+ let headings = 0;
378
+ const kept = [];
379
+ for (const line of lines) {
380
+ if (/^\s*#{1,6}\s+/.test(line)) {
381
+ headings += 1;
382
+ if (headings === 2) {
383
+ break;
384
+ }
385
+ }
386
+ kept.push(line);
387
+ }
388
+ return kept.join('\n');
389
+ };
390
+ /** A quoted Markdown body — translated, and optionally cut to its first section. */
391
+ const markdownQuote = (body, whole) => {
392
+ if (!body) {
393
+ return null;
394
+ }
395
+ const html = markdownToTelegram(whole ? body : firstSection(body));
396
+ return html ? quoteHtml(html) : null;
397
+ };
268
398
  /**
269
399
  * A quote with a caption. A bare quote reads as a continuation of the
270
400
  * field above it: the owner asked about the line that opens a session,
@@ -354,19 +484,22 @@ const renderGroup = (g) => [
354
484
  * was the same row the issue card had already lost for the same reason: you
355
485
  * could not read what the card was about without reading two lines.
356
486
  */
357
- const commitRow = (hash, title) => {
358
- // The title is the content what the commit DID so it is what the row
359
- // says. The hash is a pointer, and pointers live in the last block with
360
- // `Log`, `Check` and `Source`; the owner caught the hash still carrying the
361
- // link from the middle of the card (31.08.2026): "source, это hash", and on
362
- // a manual deploy that buried link was the card's ONLY way back to its
363
- // source while the pointer block sat empty.
364
- //
365
- // With no title there is nothing to say but the hash, so it stands here as
366
- // plain text — the link to it is in the pointer block either way.
367
- return field('Commit', title ? firstLine(title) : hash);
487
+ const commitRow = (hash, url, title) => {
488
+ // The hash IS the link, and the title stands beside it: `Commit: <a>9b1fc68</a>
489
+ // · feat: …`. The hash spent two days (31.08–03.09.2026) as plain text in the
490
+ // middle of the card with its link parked in a `Source:` row at the bottom,
491
+ // where the same hash was printed a second time. The owner: "хэш можно
492
+ // сделать кликабельным" a pointer is clickable where it stands, and the
493
+ // separate row that repeated it is gone.
494
+ if (!hash && !title) {
495
+ return null;
496
+ }
497
+ const head = hash ? (url ? `<a href="${esc(firstLine(url))}">${esc(firstLine(hash))}</a>` : esc(firstLine(hash))) : '';
498
+ const name = title ? esc(firstLine(title)) : '';
499
+ const value = head && name ? `${head} · ${name}` : head || name;
500
+ return `<b>Commit:</b> ${value}`;
368
501
  };
369
- const bodyQuote = (body) => body ? note(body) : null;
502
+ const bodyQuote = (body) => body ? markdownQuote(body, true) : null;
370
503
  /**
371
504
  * Labelled rows, sorted into the groups the sender itself named.
372
505
  *
@@ -386,7 +519,7 @@ const bodyQuote = (body) => body ? note(body) : null;
386
519
  * about the card itself, not about any one of its subjects.
387
520
  */
388
521
  const labelled = (rows) => {
389
- const list = rows ?? [];
522
+ const list = (rows ?? []).filter(([, value]) => !isZeroStill(value));
390
523
  const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
391
524
  if (names.length === 0) {
392
525
  return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
@@ -519,10 +652,12 @@ const renderDeploy = (e) => {
519
652
  // enough: a plain 🔴 next to a workflow name still read as "something
520
653
  // happened," not "it failed," on a screen small enough to lose the color.
521
654
  // The `Via` row is gone: it used to carry this same name one floor below.
522
- // The run URL is gone from this line too it is the `Source:` row now.
523
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
655
+ // The run URL rides on the name (03.09.2026): the thing you read is the
656
+ // thing you tap. It spent three days in a `Source:` row at the bottom, and
657
+ // the owner asked what that row was for when the name was right there.
658
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
524
659
  ...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
525
- commitRow(e.commit, e.commitTitle),
660
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
526
661
  fieldPerson('Author', e.commitAuthor),
527
662
  bodyQuote(e.commitBody)
528
663
  ])
@@ -547,10 +682,10 @@ const renderJob = (e) => {
547
682
  // icon says and what the third tag now says too. The icon table on the
548
683
  // catalogue page defines both marks.
549
684
  return join([
550
- // The URL moved off the type line into the `Source:` row of the pointer
551
- // block (v2.1, rule S): the owner asked for a pointer he can SEE, and a
552
- // link riding invisibly on the name is not one.
553
- typeLine(icon, 'Job', e.job, undefined, e.aside),
685
+ // The URL is on the name (03.09.2026). It went down to a `Source:` row in
686
+ // v2.1 so the pointer could be SEEN and came back, because the row's
687
+ // only text was `workflow run`, the same two words on every card.
688
+ typeLine(icon, 'Job', e.job, sourceUrl(e), e.aside),
554
689
  reason('Reason', e.note),
555
690
  field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
556
691
  // The timetable is a different subject from this event: how often the task
@@ -581,7 +716,7 @@ const renderReport = (e) => {
581
716
  // without a word.
582
717
  const numbers = labelled(e.lines);
583
718
  return join([
584
- typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
719
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
585
720
  // Rows with no group of their own sit flush against the header instead of
586
721
  // forming a separate slab under a blank line. `labelled` puts the blank
587
722
  // line before the first group itself, so there is none here.
@@ -592,8 +727,9 @@ const renderReport = (e) => {
592
727
  }
593
728
  const items = bullets(e.items, false);
594
729
  return join([
595
- // The day's snapshot link is the `Source:` row now, same as every URL.
596
- typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
730
+ // The day's snapshot link is on the title — `Source: report` under a card
731
+ // headed Report named nothing.
732
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
597
733
  // Flush against the header — see the branch above.
598
734
  ...labelled(e.lines),
599
735
  items.length > 0 ? '' : null,
@@ -608,8 +744,8 @@ const renderReport = (e) => {
608
744
  const renderCi = (e) => {
609
745
  const icon = iconFor(e);
610
746
  return join([
611
- // The run URL is the `Source:` row now, not an invisible link on the name.
612
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
747
+ // The run URL is on the gate's name `CI: <a>nightly</a>` since 03.09.2026.
748
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
613
749
  // `Actor` used to be read as "who wrote the commit," and on most runs it
614
750
  // is — `github.actor` for a push IS the person who pushed. It stops being
615
751
  // that on a scheduled run: arvent's nightly rewrites it to whoever is on
@@ -619,7 +755,7 @@ const renderCi = (e) => {
619
755
  // on a nightly card, the same person everywhere else.
620
756
  ...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
621
757
  fieldTelegram('Actor', e.actor),
622
- commitRow(e.commit, e.commitTitle),
758
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
623
759
  fieldPerson('Author', e.commitAuthor),
624
760
  bodyQuote(e.commitBody)
625
761
  ])
@@ -646,10 +782,22 @@ const renderCi = (e) => {
646
782
  // reads — the card would name a task without saying which. Same law as
647
783
  // `fieldLink`: an identifier must not vanish just because the caller passed no
648
784
  // address for it.
649
- const named = (number, title, url) => {
650
- if (!title)
651
- return url ? '' : `#${number}`;
652
- return url ? title : `#${number} ${title}`;
785
+ /**
786
+ * Line 2 of a pull request or an issue, since 03.09.2026:
787
+ *
788
+ * 🎉 <b>PR</b> <a>#414</a> · title
789
+ *
790
+ * The number is the link and it stands FIRST, right after the type word; the
791
+ * title follows a middle dot. The owner asked for exactly this: "номер
792
+ * перенести в title и сделать кликабельным … и разделить их как-то с
793
+ * title". The number had spent three days at the bottom as `Source: #414`,
794
+ * twenty lines under a title that could not be tapped. With no url the number
795
+ * still prints, plain. With no title the line ends at the number.
796
+ */
797
+ const numberedLine = (icon, type, number, title, url) => {
798
+ const id = url ? `<a href="${esc(firstLine(url))}">#${number}</a>` : `#${number}`;
799
+ const name = title?.trim() ? ` · ${esc(firstLine(title.trim()))}` : '';
800
+ return `${icon} <b>${esc(type)}</b> ${id}${name}`;
653
801
  };
654
802
  // The people come BEFORE the text, and the text comes only when it is the
655
803
  // news. An `assigned` card carries one new fact — who took it — and it used to
@@ -671,14 +819,24 @@ const named = (number, title, url) => {
671
819
  // own comment there, not the PR description — and that is what the caption
672
820
  // has to say.
673
821
  const VERDICT = new Set(['approved', 'changes_requested']);
674
- const prBody = (action, body) => VERDICT.has(action) ? quoted('Review', body) : bodyQuote(body);
822
+ // The whole body is news exactly once — when the thing is opened, or when the
823
+ // text is a reviewer's own comment. On merged and closed the card keeps the
824
+ // first section only (what changed for the user); the rest is one tap away
825
+ // behind the number. Every body is Markdown from GitHub and is translated.
826
+ const prBody = (action, body) => {
827
+ if (VERDICT.has(action)) {
828
+ const html = markdownQuote(body, true);
829
+ return html ? `<b>Review:</b>\n${html}` : null;
830
+ }
831
+ return markdownQuote(body, action === 'opened');
832
+ };
675
833
  // The body is what the title stands for — it sits directly under the name,
676
834
  // with nothing between them. The people come after, consolidated in one
677
835
  // place, never splitting the title from what it names: the owner on the
678
836
  // old order, title then Author then Assignee then finally the body — "why
679
837
  // does the assignee cut apart what should be inseparable?"
680
838
  const renderPr = (e) => join([
681
- typeLine(iconFor(e), 'PR', named(e.number, e.title, e.url)),
839
+ numberedLine(iconFor(e), 'PR', e.number, e.title, e.url),
682
840
  prBody(e.action, e.body),
683
841
  e.body ? '' : null,
684
842
  fieldPerson('Author', e.author),
@@ -690,9 +848,11 @@ const renderPr = (e) => join([
690
848
  // полезно, не переходя по ссылке на источник." A collapsed quote costs four
691
849
  // lines, which is what the earlier "he already saw it" reasoning was trying
692
850
  // to save — the expandable quote buys the context back without the cost.
851
+ // Whole on opened and assigned (the text is still the news, or the person who
852
+ // took it needs the whole brief); the first section on closed — see prBody.
693
853
  const renderIssue = (e) => join([
694
- typeLine(iconFor(e), 'Issue', named(e.number, e.title, e.url)),
695
- bodyQuote(e.body),
854
+ numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url),
855
+ markdownQuote(e.body, e.action !== 'closed'),
696
856
  e.body ? '' : null,
697
857
  fieldPerson('Author', e.author),
698
858
  fieldPerson('Assignee', e.assignee)
@@ -708,7 +868,7 @@ const renderIssue = (e) => join([
708
868
  const renderIncident = (e) => {
709
869
  const findings = bullets(e.items, false);
710
870
  return join([
711
- typeLine(iconFor(e), 'Incident', e.title),
871
+ typeLine(iconFor(e), 'Incident', e.title, e.url),
712
872
  e.detail && e.detail !== e.title ? note(e.detail) : null,
713
873
  field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
714
874
  findings.length > 0 ? '' : null,
@@ -857,67 +1017,25 @@ export const OUTCOME_TAG = {
857
1017
  export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
858
1018
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
859
1019
  /**
860
- * Rule S (v2.1): the card's last block says where to verify it. `Source:` is
861
- * a hyperlink when the event has a canonical URL; `Check:` is a local
862
- * command; `Log:` is a path and only ever an ADDITION a path cannot be
863
- * tapped, only copied. The link text is a short English noun naming what
864
- * opens, never the click.
1020
+ * Rule S (v2.1, amended 03.09.2026): the card says where to verify it. A
1021
+ * link rides on the NAME of the thing on line 2 (the run, the report, the
1022
+ * number of a PR or an issue) and on the commit hash; the last block keeps
1023
+ * only what is not a link `Check:` is a local command, `Log:` is a path,
1024
+ * and a path cannot be tapped, only copied.
1025
+ *
1026
+ * The `Source:` row that held the links from 31.08 to 03.09 is gone: its text
1027
+ * was `workflow run` on every deploy, `report` under every report, and a
1028
+ * number twenty lines below the title it belonged to. The owner: "Зачем это
1029
+ * всё? … Если ссылка подразумевается, она должна лежать сразу в title."
865
1030
  */
866
- const SOURCE_NAME = {
867
- deploy: 'workflow run',
868
- ci: 'workflow run',
869
- job: 'workflow run',
870
- report: 'report',
871
- pr: 'pull request',
872
- issue: 'issue',
873
- incident: 'details',
874
- session: 'details',
875
- heartbeat_miss: 'details'
876
- };
877
1031
  const sourceUrl = (e) => {
878
1032
  const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
879
1033
  const url = 'url' in e ? e.url : undefined;
880
1034
  return wf ?? url;
881
1035
  };
882
- const link = (url, text) => `<a href="${esc(firstLine(url))}">${esc(text)}</a>`;
883
- /**
884
- * Everything the card can be traced back to, as one `Source:` row.
885
- *
886
- * A card can have more than one honest source — a deploy through Actions has
887
- * both a run and the commit that triggered it — and one label with two links
888
- * beside each other reads better than two rows both called `Source`. They are
889
- * ordered widest first: the run contains the commit, not the other way round.
890
- */
891
- const sourceLinks = (e) => {
892
- const out = [];
893
- const run = sourceUrl(e);
894
- const commitUrl = 'commitUrl' in e ? e.commitUrl : undefined;
895
- const commit = 'commit' in e ? e.commit : undefined;
896
- // A pull request and an issue are named by their number and by NOTHING else.
897
- // `pull request #118` says the type a second time — line 2 already opens with
898
- // `PR:` — and the owner cut it the hour it shipped: "сверху написано PR,
899
- // зачем здесь ещё писать PR? Здесь можно просто номер и всё."
900
- //
901
- // Only these two types have a number, and only for them is the type word
902
- // redundant: `workflow run` and `commit 9b1fc68` name things line 2 does NOT,
903
- // so they keep their nouns.
904
- const numbered = 'number' in e && typeof e.number === 'number' ? `#${e.number}` : null;
905
- if (run)
906
- out.push(link(run, numbered ?? SOURCE_NAME[e.type] ?? 'source'));
907
- // Without a hash there is no text to put on the link but the word itself,
908
- // and `commit` alone next to `workflow run` says nothing a reader can use.
909
- if (commitUrl && commit)
910
- out.push(link(commitUrl, `commit ${firstLine(commit)}`));
911
- return out;
912
- };
913
1036
  const pointerBlock = (e) => {
914
- const sources = sourceLinks(e);
915
1037
  const logs = 'logs' in e ? e.logs : undefined;
916
- const rows = [
917
- fieldCode('Log', logs),
918
- fieldCode('Check', e.check),
919
- sources.length > 0 ? `<b>Source:</b> ${sources.join(' · ')}` : null
920
- ].filter((r) => r !== null);
1038
+ const rows = [fieldCode('Log', logs), fieldCode('Check', e.check)].filter((r) => r !== null);
921
1039
  return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
922
1040
  };
923
1041
  /**
@@ -937,17 +1055,18 @@ const cutMarker = (e) => {
937
1055
  // `Source` link the whole time: the rest was one tap away and the marker
938
1056
  // never said so.
939
1057
  //
940
- // So the marker NAMES the row that holds the rest, and never repeats its
1058
+ // So the marker NAMES the place that holds the rest, and never repeats its
941
1059
  // value: it used to print the log path in full, and the pointer block then
942
1060
  // printed the same path again on the very next line.
943
1061
  //
944
1062
  // `below` is literal, not a figure of speech: the marker closes the clamped
945
- // body and the pointer block is appended after it.
1063
+ // body and the pointer block is appended after it. `line 2` is literal too:
1064
+ // since 03.09.2026 the link lives on the name there.
946
1065
  const logs = 'logs' in e ? e.logs : undefined;
947
1066
  if (logs) {
948
1067
  return '⋯ cut, full text at Log below';
949
1068
  }
950
- return sourceLinks(e).length > 0 ? '⋯ cut, full text at Source below' : '⋯ cut';
1069
+ return sourceUrl(e) ? '⋯ cut, full text behind the link on line 2' : '⋯ cut';
951
1070
  };
952
1071
  /**
953
1072
  * Renders an event into finished HTML text, cut to Telegram's limit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",