@mikitasazan/notify 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lint.js +3 -3
- package/dist/render.d.ts +22 -0
- package/dist/render.js +218 -99
- 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,
|
|
@@ -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
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 ?
|
|
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
|
|
523
|
-
|
|
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
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
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),
|
|
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,
|
|
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
|
|
596
|
-
|
|
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
|
|
612
|
-
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'),
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
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
|
|
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
|
|
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