@mikitasazan/notify 1.13.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 +3 -3
- package/dist/render.d.ts +22 -0
- package/dist/render.js +243 -54
- package/package.json +1 -1
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
|
|
145
|
-
//
|
|
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 `` 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 `` → 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
|
-
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
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 `` 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 `` → 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,
|
|
@@ -355,13 +485,21 @@ const renderGroup = (g) => [
|
|
|
355
485
|
* could not read what the card was about without reading two lines.
|
|
356
486
|
*/
|
|
357
487
|
const commitRow = (hash, url, title) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
|
361
496
|
}
|
|
362
|
-
|
|
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}`;
|
|
363
501
|
};
|
|
364
|
-
const bodyQuote = (body) => body ?
|
|
502
|
+
const bodyQuote = (body) => body ? markdownQuote(body, true) : null;
|
|
365
503
|
/**
|
|
366
504
|
* Labelled rows, sorted into the groups the sender itself named.
|
|
367
505
|
*
|
|
@@ -381,7 +519,7 @@ const bodyQuote = (body) => body ? note(body) : null;
|
|
|
381
519
|
* about the card itself, not about any one of its subjects.
|
|
382
520
|
*/
|
|
383
521
|
const labelled = (rows) => {
|
|
384
|
-
const list = rows ?? [];
|
|
522
|
+
const list = (rows ?? []).filter(([, value]) => !isZeroStill(value));
|
|
385
523
|
const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
|
|
386
524
|
if (names.length === 0) {
|
|
387
525
|
return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
|
|
@@ -514,8 +652,10 @@ const renderDeploy = (e) => {
|
|
|
514
652
|
// enough: a plain 🔴 next to a workflow name still read as "something
|
|
515
653
|
// happened," not "it failed," on a screen small enough to lose the color.
|
|
516
654
|
// The `Via` row is gone: it used to carry this same name one floor below.
|
|
517
|
-
// The run URL
|
|
518
|
-
|
|
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'),
|
|
519
659
|
...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
520
660
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
521
661
|
fieldPerson('Author', e.commitAuthor),
|
|
@@ -542,10 +682,10 @@ const renderJob = (e) => {
|
|
|
542
682
|
// icon says and what the third tag now says too. The icon table on the
|
|
543
683
|
// catalogue page defines both marks.
|
|
544
684
|
return join([
|
|
545
|
-
// The URL
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
typeLine(icon, 'Job', e.job,
|
|
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),
|
|
549
689
|
reason('Reason', e.note),
|
|
550
690
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
551
691
|
// The timetable is a different subject from this event: how often the task
|
|
@@ -576,7 +716,7 @@ const renderReport = (e) => {
|
|
|
576
716
|
// without a word.
|
|
577
717
|
const numbers = labelled(e.lines);
|
|
578
718
|
return join([
|
|
579
|
-
typeLine(iconFor(e), 'Report', e.title,
|
|
719
|
+
typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
|
|
580
720
|
// Rows with no group of their own sit flush against the header instead of
|
|
581
721
|
// forming a separate slab under a blank line. `labelled` puts the blank
|
|
582
722
|
// line before the first group itself, so there is none here.
|
|
@@ -587,8 +727,9 @@ const renderReport = (e) => {
|
|
|
587
727
|
}
|
|
588
728
|
const items = bullets(e.items, false);
|
|
589
729
|
return join([
|
|
590
|
-
// The day's snapshot link is the `Source
|
|
591
|
-
|
|
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),
|
|
592
733
|
// Flush against the header — see the branch above.
|
|
593
734
|
...labelled(e.lines),
|
|
594
735
|
items.length > 0 ? '' : null,
|
|
@@ -603,8 +744,8 @@ const renderReport = (e) => {
|
|
|
603
744
|
const renderCi = (e) => {
|
|
604
745
|
const icon = iconFor(e);
|
|
605
746
|
return join([
|
|
606
|
-
// The run URL is the
|
|
607
|
-
typeLine(icon, 'CI', mechanism(e.workflowName, undefined),
|
|
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'),
|
|
608
749
|
// `Actor` used to be read as "who wrote the commit," and on most runs it
|
|
609
750
|
// is — `github.actor` for a push IS the person who pushed. It stops being
|
|
610
751
|
// that on a scheduled run: arvent's nightly rewrites it to whoever is on
|
|
@@ -626,7 +767,38 @@ const renderCi = (e) => {
|
|
|
626
767
|
// the thing the card is about could not be read without reading three lines.
|
|
627
768
|
// The action is not repeated in words: the icon carries it, and no two actions
|
|
628
769
|
// of one type share an icon.
|
|
629
|
-
|
|
770
|
+
// The number left line 2 on 31.08.2026, by the same rule that moved the commit
|
|
771
|
+
// hash the same day: `#347` is a pointer, and every pointer lives in the last
|
|
772
|
+
// block. Line 2 keeps the title, which is what the thing IS; the number comes
|
|
773
|
+
// back as the text of the `Source` link, where it names exactly what opens.
|
|
774
|
+
//
|
|
775
|
+
// With no title there is nothing left to say, so line 2 falls back to the bare
|
|
776
|
+
// type word — `typeLine` already does that on an empty name. The number is not
|
|
777
|
+
// put back here: it would then appear twice on a titled card and once on an
|
|
778
|
+
// untitled one, which is the inconsistency this move exists to remove.
|
|
779
|
+
//
|
|
780
|
+
// With NO url the number stays put, because then the `Source` row does not
|
|
781
|
+
// exist and dropping it here would erase the number from everything a person
|
|
782
|
+
// reads — the card would name a task without saying which. Same law as
|
|
783
|
+
// `fieldLink`: an identifier must not vanish just because the caller passed no
|
|
784
|
+
// address for it.
|
|
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}`;
|
|
801
|
+
};
|
|
630
802
|
// The people come BEFORE the text, and the text comes only when it is the
|
|
631
803
|
// news. An `assigned` card carries one new fact — who took it — and it used to
|
|
632
804
|
// sit dead last, under the issue's entire description: the owner read a card
|
|
@@ -647,14 +819,24 @@ const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
|
|
|
647
819
|
// own comment there, not the PR description — and that is what the caption
|
|
648
820
|
// has to say.
|
|
649
821
|
const VERDICT = new Set(['approved', 'changes_requested']);
|
|
650
|
-
|
|
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
|
+
};
|
|
651
833
|
// The body is what the title stands for — it sits directly under the name,
|
|
652
834
|
// with nothing between them. The people come after, consolidated in one
|
|
653
835
|
// place, never splitting the title from what it names: the owner on the
|
|
654
836
|
// old order, title then Author then Assignee then finally the body — "why
|
|
655
837
|
// does the assignee cut apart what should be inseparable?"
|
|
656
838
|
const renderPr = (e) => join([
|
|
657
|
-
|
|
839
|
+
numberedLine(iconFor(e), 'PR', e.number, e.title, e.url),
|
|
658
840
|
prBody(e.action, e.body),
|
|
659
841
|
e.body ? '' : null,
|
|
660
842
|
fieldPerson('Author', e.author),
|
|
@@ -666,9 +848,11 @@ const renderPr = (e) => join([
|
|
|
666
848
|
// полезно, не переходя по ссылке на источник." A collapsed quote costs four
|
|
667
849
|
// lines, which is what the earlier "he already saw it" reasoning was trying
|
|
668
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.
|
|
669
853
|
const renderIssue = (e) => join([
|
|
670
|
-
|
|
671
|
-
|
|
854
|
+
numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url),
|
|
855
|
+
markdownQuote(e.body, e.action !== 'closed'),
|
|
672
856
|
e.body ? '' : null,
|
|
673
857
|
fieldPerson('Author', e.author),
|
|
674
858
|
fieldPerson('Assignee', e.assignee)
|
|
@@ -684,7 +868,7 @@ const renderIssue = (e) => join([
|
|
|
684
868
|
const renderIncident = (e) => {
|
|
685
869
|
const findings = bullets(e.items, false);
|
|
686
870
|
return join([
|
|
687
|
-
typeLine(iconFor(e), 'Incident', e.title),
|
|
871
|
+
typeLine(iconFor(e), 'Incident', e.title, e.url),
|
|
688
872
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
689
873
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
690
874
|
findings.length > 0 ? '' : null,
|
|
@@ -833,36 +1017,25 @@ export const OUTCOME_TAG = {
|
|
|
833
1017
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
834
1018
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
835
1019
|
/**
|
|
836
|
-
* Rule S (v2.1): the card
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
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."
|
|
841
1030
|
*/
|
|
842
|
-
const SOURCE_NAME = {
|
|
843
|
-
deploy: 'workflow run',
|
|
844
|
-
ci: 'workflow run',
|
|
845
|
-
job: 'workflow run',
|
|
846
|
-
report: 'report',
|
|
847
|
-
pr: 'pull request',
|
|
848
|
-
issue: 'issue',
|
|
849
|
-
incident: 'details',
|
|
850
|
-
session: 'details',
|
|
851
|
-
heartbeat_miss: 'details'
|
|
852
|
-
};
|
|
853
1031
|
const sourceUrl = (e) => {
|
|
854
1032
|
const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
|
|
855
1033
|
const url = 'url' in e ? e.url : undefined;
|
|
856
1034
|
return wf ?? url;
|
|
857
1035
|
};
|
|
858
1036
|
const pointerBlock = (e) => {
|
|
859
|
-
const url = sourceUrl(e);
|
|
860
1037
|
const logs = 'logs' in e ? e.logs : undefined;
|
|
861
|
-
const rows = [
|
|
862
|
-
fieldCode('Log', logs),
|
|
863
|
-
fieldCode('Check', e.check),
|
|
864
|
-
url ? `<b>Source:</b> <a href="${esc(url)}">${esc(SOURCE_NAME[e.type] ?? 'source')}</a>` : null
|
|
865
|
-
].filter((r) => r !== null);
|
|
1038
|
+
const rows = [fieldCode('Log', logs), fieldCode('Check', e.check)].filter((r) => r !== null);
|
|
866
1039
|
return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
|
|
867
1040
|
};
|
|
868
1041
|
/**
|
|
@@ -876,8 +1049,24 @@ const cutMarker = (e) => {
|
|
|
876
1049
|
if (e.path) {
|
|
877
1050
|
return '⋯ cut, full text attached';
|
|
878
1051
|
}
|
|
1052
|
+
// A bare `⋯ cut` announces a loss and then says nothing about it. The owner
|
|
1053
|
+
// met one on a live PR card (31.08.2026) — a description long enough to be
|
|
1054
|
+
// clamped — and asked what the three dots even referred to. The card had a
|
|
1055
|
+
// `Source` link the whole time: the rest was one tap away and the marker
|
|
1056
|
+
// never said so.
|
|
1057
|
+
//
|
|
1058
|
+
// So the marker NAMES the place that holds the rest, and never repeats its
|
|
1059
|
+
// value: it used to print the log path in full, and the pointer block then
|
|
1060
|
+
// printed the same path again on the very next line.
|
|
1061
|
+
//
|
|
1062
|
+
// `below` is literal, not a figure of speech: the marker closes the clamped
|
|
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.
|
|
879
1065
|
const logs = 'logs' in e ? e.logs : undefined;
|
|
880
|
-
|
|
1066
|
+
if (logs) {
|
|
1067
|
+
return '⋯ cut, full text at Log below';
|
|
1068
|
+
}
|
|
1069
|
+
return sourceUrl(e) ? '⋯ cut, full text behind the link on line 2' : '⋯ cut';
|
|
881
1070
|
};
|
|
882
1071
|
/**
|
|
883
1072
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
package/package.json
CHANGED