@mikitasazan/notify 1.7.0 → 1.8.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/cli-flags.js +1 -1
- package/dist/cli.js +73 -61
- package/dist/events.d.ts +142 -118
- package/dist/events.js +23 -21
- package/dist/render.d.ts +48 -45
- package/dist/render.js +233 -198
- package/dist/routes.d.ts +31 -27
- package/dist/routes.js +25 -20
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* One renderer per event type, all built on one skeleton — approved by
|
|
3
|
+
* the owner on 20.08.2026 after ~15 live rounds in the test forum:
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* #type #instance
|
|
6
|
+
* icon <b>Type:</b> action
|
|
7
7
|
*
|
|
8
|
-
* <b
|
|
9
|
-
* <blockquote
|
|
8
|
+
* <b>Label:</b> value
|
|
9
|
+
* <blockquote>quoted text from someone else — commit body, task body</blockquote>
|
|
10
10
|
*
|
|
11
|
-
* <i><u
|
|
12
|
-
* <b>#N (overdue):</b> <a
|
|
11
|
+
* <i><u>Group</u></i>
|
|
12
|
+
* <b>#N (overdue):</b> <a>title</a>
|
|
13
13
|
*
|
|
14
|
-
* <b
|
|
14
|
+
* <b>Label:</b> value ← actions/directions
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Three levels of styling, never mixed: a field is a bold, capitalized
|
|
17
|
+
* label plus a plain value; a group is italic+underline, no bold and no
|
|
18
|
+
* colon; line 2 (the type) follows the same field rule. A blank line
|
|
19
|
+
* separates BLOCKS BY MEANING (header / body / actions), not mechanically
|
|
20
|
+
* after every line.
|
|
21
21
|
*/
|
|
22
22
|
import { ICON, iconFor } from "./events.js";
|
|
23
|
-
/**
|
|
23
|
+
/** First letter capitalized, the rest left as is (ga4/GitHub stay themselves). */
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `iOS`
|
|
25
|
+
* A label gets a capital letter — but NOT a name that is deliberately
|
|
26
|
+
* written lowercase: `iOS` was turning into `IOS`. The signal is a capital
|
|
27
|
+
* second letter.
|
|
27
28
|
*/
|
|
28
29
|
const cap = (s) => {
|
|
29
30
|
if (s.length === 0 || /^[a-z][A-Z]/.test(s)) {
|
|
@@ -31,24 +32,25 @@ const cap = (s) => {
|
|
|
31
32
|
}
|
|
32
33
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
33
34
|
};
|
|
34
|
-
/**
|
|
35
|
+
/** Escapes EVERYTHING that comes from outside — only the template adds tags. */
|
|
35
36
|
export const esc = (v) => String(v ?? '')
|
|
36
37
|
.replace(/&/g, '&')
|
|
37
38
|
.replace(/</g, '<')
|
|
38
39
|
.replace(/>/g, '>')
|
|
39
40
|
.replace(/"/g, '"');
|
|
40
41
|
/**
|
|
41
|
-
* Telegram
|
|
42
|
-
*
|
|
42
|
+
* Telegram cuts a message at 4096 characters — we cut it ourselves first,
|
|
43
|
+
* on a line boundary where possible.
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
* 1.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
45
|
+
* Two traps, both caused a SILENT loss of the message:
|
|
46
|
+
* 1. Cutting strictly at the last `\n` does not work: if a long chunk runs
|
|
47
|
+
* as one line (a stack trace, command output — the most common `detail`
|
|
48
|
+
* on an incident), the last line break sits BEFORE it, and the whole
|
|
49
|
+
* content got dropped — only the heading arrived, with not a single
|
|
50
|
+
* fact about what broke.
|
|
51
|
+
* 2. Cutting in the middle of an HTML tag or entity does not work either:
|
|
52
|
+
* Telegram replies `400 can't parse entities`, and we treat a 4xx as a
|
|
53
|
+
* permanent error and do not retry — the message disappeared for good.
|
|
52
54
|
*/
|
|
53
55
|
export const clampMessage = (text, limit = 4000) => {
|
|
54
56
|
if (text.length <= limit) {
|
|
@@ -56,9 +58,9 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
56
58
|
}
|
|
57
59
|
const cut = text.slice(0, limit);
|
|
58
60
|
const lastBreak = cut.lastIndexOf('\n');
|
|
59
|
-
//
|
|
61
|
+
// Cut on a line boundary — only if that keeps most of the content.
|
|
60
62
|
let end = lastBreak > limit * 0.6 ? lastBreak : limit;
|
|
61
|
-
//
|
|
63
|
+
// Never cut inside `<...>` or inside `&...;` — otherwise the markup breaks.
|
|
62
64
|
const openTag = cut.lastIndexOf('<', end - 1);
|
|
63
65
|
if (openTag !== -1 && cut.indexOf('>', openTag) === -1) {
|
|
64
66
|
end = openTag;
|
|
@@ -68,21 +70,22 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
68
70
|
end = amp;
|
|
69
71
|
}
|
|
70
72
|
const body = cut.slice(0, end);
|
|
71
|
-
//
|
|
72
|
-
// blockquote —
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
73
|
+
// The clamp may have cut off closing tags — we add them back so the markup
|
|
74
|
+
// matches. blockquote — since quoting arrived for notes/details, a long
|
|
75
|
+
// detail gets cut right in the middle of it, and without this tag Telegram
|
|
76
|
+
// would answer 400 on the unclosed quote (the regex `<blockquote[ >]` also
|
|
77
|
+
// catches the variant with an `expandable` attribute).
|
|
78
|
+
// `u` joined the list on 2026-08-25: a group heading renders as
|
|
79
|
+
// `<i><u>…</u></i>`, and a long heading cut in the middle left `<u>` unclosed.
|
|
80
|
+
// Telegram answers 400 to that — meaning the whole card disappeared, and the
|
|
81
|
+
// sender with `|| true` never noticed. Codex found it; reproduces with a
|
|
82
|
+
// report whose group name is 5000 characters long.
|
|
80
83
|
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
84
|
+
// Closing order is the reverse of OPENING order, not a fixed list. A fixed
|
|
85
|
+
// list closed `<i><u>` as `</i></u>`: the tag count matches, but the nesting
|
|
86
|
+
// is wrong, and Telegram answers with the same 400. Second round of the same
|
|
87
|
+
// bug (25.08.2026), so now the order comes from the text itself: the last
|
|
88
|
+
// one opened is the first one closed.
|
|
86
89
|
const open = [];
|
|
87
90
|
const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
|
|
88
91
|
for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
|
|
@@ -102,12 +105,12 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
102
105
|
.join('');
|
|
103
106
|
return `${body}${tail}\n…`;
|
|
104
107
|
};
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
108
|
+
// Only the first line: a field is single-line by contract (commit, branch,
|
|
109
|
+
// author, a stat), not a place for a paragraph. A live case (18.08): a CI card
|
|
110
|
+
// carried the FULL commit body with a sub-commit history through `--commit`
|
|
111
|
+
// and instead of one line unrolled to 3000 characters — multi-line text is
|
|
112
|
+
// either a caller mistake, or it belongs in `note()`, not a silent bloat of
|
|
113
|
+
// the card.
|
|
111
114
|
const firstLine = (value) => {
|
|
112
115
|
if (typeof value === 'number' || !value.includes('\n')) {
|
|
113
116
|
return value;
|
|
@@ -115,16 +118,16 @@ const firstLine = (value) => {
|
|
|
115
118
|
return `${value.split('\n')[0]}…`;
|
|
116
119
|
};
|
|
117
120
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* A field: `<b>Label:</b> value` — a bold, capitalized label, a plain value.
|
|
122
|
+
* `null` is dropped the same as `undefined`/`''` — the field's sources are
|
|
123
|
+
* JSON on stdin (`--json`) and objects from the server, where a missing
|
|
124
|
+
* value serializes as `null`, not as a missing key.
|
|
122
125
|
*/
|
|
123
126
|
const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
|
|
124
127
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
+
* An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
|
|
129
|
+
* one exists, otherwise the plain text of the same field — an identifier
|
|
130
|
+
* must not disappear entirely just because the caller did not pass a url.
|
|
128
131
|
*/
|
|
129
132
|
const fieldLink = (label, url, text) => {
|
|
130
133
|
if (text === undefined || text === null || text === '') {
|
|
@@ -138,41 +141,44 @@ const fieldLink = (label, url, text) => {
|
|
|
138
141
|
: field(label, text);
|
|
139
142
|
};
|
|
140
143
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
+
* An action field (`workflow:`): unlike `fieldLink`, without a URL this is
|
|
145
|
+
* NOT a field — the run simply has nowhere to lead, and showing the bare
|
|
146
|
+
* word "run" with no link is more meaningless than not showing the row at
|
|
147
|
+
* all.
|
|
144
148
|
*/
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
+
// The link text is the name of where it leads (the workflow's name, the run's
|
|
150
|
+
// name). The fallback word used to be "run": a noun that names nothing — the
|
|
151
|
+
// owner read "Workflow: run" and did not understand what it was. "open" is a
|
|
152
|
+
// verb, and it is at least honest about being a link, not a name.
|
|
149
153
|
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
150
|
-
/**
|
|
154
|
+
/** A monospace field — a path/command to copy, not a link. */
|
|
151
155
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
152
156
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* A row that asks something FROM THE OWNER, rather than reports a fact. It
|
|
158
|
+
* already stood last, set off by a blank line, and still read as an
|
|
159
|
+
* ordinary field among five others. The `▶` marker is the only difference:
|
|
160
|
+
* a group heading here would have been a third line of markup on a card
|
|
161
|
+
* that already has six (25.08.2026, two reviews against grouping), and the
|
|
162
|
+
* marker spends none.
|
|
158
163
|
*/
|
|
159
164
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
165
|
+
* Action: what to do and with what. Without an explanation the command is
|
|
166
|
+
* NOT printed at all — the owner on a bare `rm` in a card: "I don't even
|
|
167
|
+
* know what I'm doing." Silently dropping the row is better than showing
|
|
168
|
+
* him a command he cannot read; the sender is caught by the catalogue test
|
|
169
|
+
* instead, not by silence in the chat.
|
|
164
170
|
*/
|
|
165
171
|
const fieldRun = (value, why) => {
|
|
166
172
|
const explain = field('To do', why);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// Telegram
|
|
173
|
+
// No icon. `▶` was the only symbol of its kind across all twenty kinds of
|
|
174
|
+
// cards, and the owner rightly asked what it meant: nothing that the row
|
|
175
|
+
// `To do:` above it and the monospace font below it did not already say —
|
|
176
|
+
// Telegram makes a row like that tap-to-copy on its own.
|
|
171
177
|
return value && explain !== null ? [explain, `<code>${esc(value)}</code>`] : [];
|
|
172
178
|
};
|
|
173
|
-
/**
|
|
179
|
+
/** A group heading: italic + underline, no bold, no colon. */
|
|
174
180
|
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
175
|
-
/**
|
|
181
|
+
/** An item inside a group: `<b>label:</b> <a>text</a>` — or a plain bulleted/numbered row with no label. */
|
|
176
182
|
const groupItem = (it, index, numbered) => {
|
|
177
183
|
const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
|
|
178
184
|
if (it.label) {
|
|
@@ -180,10 +186,11 @@ const groupItem = (it, index, numbered) => {
|
|
|
180
186
|
}
|
|
181
187
|
return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
|
|
182
188
|
};
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// Bot API),
|
|
189
|
+
// A long explanation (a note, incident details) — as a quote: in Telegram
|
|
190
|
+
// that is a bar on the left and a light indent, reading as "details," not as
|
|
191
|
+
// part of the heading. Longer than ~400 characters and the quote collapses
|
|
192
|
+
// on its own (`expandable`, Bot API), otherwise a stack trace or a log dump
|
|
193
|
+
// stretches the card across the whole screen.
|
|
187
194
|
const EXPAND_AT = 400;
|
|
188
195
|
const note = (text) => {
|
|
189
196
|
if (!text) {
|
|
@@ -193,11 +200,12 @@ const note = (text) => {
|
|
|
193
200
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
194
201
|
};
|
|
195
202
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
203
|
+
* A quote with a caption. A bare quote reads as a continuation of the
|
|
204
|
+
* field above it: the owner asked about the line that opens a session,
|
|
205
|
+
* "what does this text mean, where does it come from" — and he was right,
|
|
206
|
+
* the card says it nowhere. The caption stands on its own line, because
|
|
207
|
+
* the text itself does not fit in a field: a field holds one line and cuts
|
|
208
|
+
* it.
|
|
201
209
|
*/
|
|
202
210
|
/**
|
|
203
211
|
* A quote that needs saying what it is. The heading is a GROUP heading — the
|
|
@@ -208,10 +216,10 @@ const note = (text) => {
|
|
|
208
216
|
*/
|
|
209
217
|
const quoted = (label, text) => text ? `${group(label)}\n${note(text)}` : null;
|
|
210
218
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
219
|
+
* Assembles the card. A blank line here marks a block change, not
|
|
220
|
+
* indentation: two in a row mean an empty block, a leading one means a
|
|
221
|
+
* block that does not exist. Both appear when some fields did not arrive,
|
|
222
|
+
* and both collapse here, not separately in every renderer.
|
|
215
223
|
*/
|
|
216
224
|
const join = (parts) => {
|
|
217
225
|
const out = [];
|
|
@@ -229,12 +237,13 @@ const join = (parts) => {
|
|
|
229
237
|
}
|
|
230
238
|
return out.join('\n');
|
|
231
239
|
};
|
|
232
|
-
/**
|
|
240
|
+
/** A flat list of items (no labels) — job/report with no groups. */
|
|
233
241
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
242
|
+
* List items. A named group ALWAYS prints its heading — the same law
|
|
243
|
+
* `labelled` follows: a card of one type must not look different on
|
|
244
|
+
* different days. Numbering runs within the block, not across it: "1, 2"
|
|
245
|
+
* under its own heading reads fine, a running "3, 4" under the second one
|
|
246
|
+
* does not.
|
|
238
247
|
*/
|
|
239
248
|
const bullets = (items, numbered) => {
|
|
240
249
|
const list = items ?? [];
|
|
@@ -251,42 +260,45 @@ const bullets = (items, numbered) => {
|
|
|
251
260
|
}
|
|
252
261
|
return out;
|
|
253
262
|
};
|
|
254
|
-
/**
|
|
263
|
+
/** A whole named group: heading + items, separated by a blank line inside the call, via join. */
|
|
255
264
|
const renderGroup = (g) => [
|
|
256
265
|
group(g.name),
|
|
257
266
|
...g.items.map((it, i) => groupItem(it, i, false))
|
|
258
267
|
];
|
|
259
268
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
269
|
+
* ONE rule for every card that has both a title and a body: the title is
|
|
270
|
+
* an ordinary `Title:` field, the body is a quote, and the quote holds
|
|
271
|
+
* nothing else.
|
|
262
272
|
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
273
|
+
* The title used to sit INSIDE THE QUOTE together with the body, separated
|
|
274
|
+
* by a blank line. The owner found the problem with that: the title is the
|
|
275
|
+
* main thing on a card, WHAT it is about, and it sat there as gray text of
|
|
276
|
+
* the same weight as the description — the only way to tell them apart was
|
|
277
|
+
* the blank line. On a PR with no body, the card degenerated into a single
|
|
278
|
+
* lonely gray one-line quote.
|
|
268
279
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
280
|
+
* The title is cut to its first line: a multi-line commit subject must not
|
|
281
|
+
* drag its own body into the field.
|
|
271
282
|
*/
|
|
272
283
|
const titleField = (title) => field('Title', title);
|
|
273
284
|
const bodyQuote = (body) => body ? note(body) : null;
|
|
274
285
|
/**
|
|
275
|
-
*
|
|
286
|
+
* Labelled rows, sorted into the groups the sender itself named.
|
|
276
287
|
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
288
|
+
* The rule is simple and enforced by code: named a group — the heading
|
|
289
|
+
* prints. Always, no matter how many rows are in it or how many groups
|
|
290
|
+
* turn up. I tried a "two or more" threshold and dropped it: on the backup
|
|
291
|
+
* card all the numbers sit in one group, with a summary of the run above
|
|
292
|
+
* them, and the threshold was killing exactly the seam the owner asked
|
|
293
|
+
* for in the first place.
|
|
282
294
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
295
|
+
* This also removes the risk of "the same card looks different on
|
|
296
|
+
* different days": the look depends on what the sender NAMED in code, not
|
|
297
|
+
* on how many rows happened to show up today.
|
|
286
298
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
299
|
+
* Group order is the order the sender first mentions them: it knows what
|
|
300
|
+
* matters more. Unnamed rows come first with no heading — they are facts
|
|
301
|
+
* about the card itself, not about any one of its subjects.
|
|
290
302
|
*/
|
|
291
303
|
const labelled = (rows) => {
|
|
292
304
|
const list = rows ?? [];
|
|
@@ -303,10 +315,10 @@ const labelled = (rows) => {
|
|
|
303
315
|
}
|
|
304
316
|
}
|
|
305
317
|
for (const name of names) {
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
// `join
|
|
318
|
+
// A blank line before EVERY heading, including the first: above it there
|
|
319
|
+
// are always the card's own fields (Task, Period), and without the seam
|
|
320
|
+
// the heading read as just another one of them. No need to worry about
|
|
321
|
+
// double blanks — `join` collapses them.
|
|
310
322
|
out.push('');
|
|
311
323
|
out.push(group(name));
|
|
312
324
|
for (const [label, value] of list.filter(([, , g]) => g === name)) {
|
|
@@ -319,18 +331,19 @@ const labelled = (rows) => {
|
|
|
319
331
|
return out;
|
|
320
332
|
};
|
|
321
333
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* actor, workflow —
|
|
334
|
+
* Blocks owned by the renderer itself — a deploy and a check have two of
|
|
335
|
+
* them, and they are about different things: `Run` is the run itself and
|
|
336
|
+
* its circumstances, `Change` is the change that caused it. The owner on a
|
|
337
|
+
* CI card: "commit, actor, workflow — I don't know, it's all a jumble."
|
|
326
338
|
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
339
|
+
* The heading prints for EVERY non-empty block, not only when there are
|
|
340
|
+
* two. It used to be "two or more," to save a line on a green card, and
|
|
341
|
+
* that turned out to be a mistake: a green deploy has no target and no
|
|
342
|
+
* reason, there is only one block, the headings disappeared — and the same
|
|
343
|
+
* kind of notification looked different from one day to the next. The
|
|
344
|
+
* owner asked twice "why isn't there a group here," looking straight at a
|
|
345
|
+
* green one. A heading row costs less than having to hunt for what is what
|
|
346
|
+
* every single time.
|
|
334
347
|
*/
|
|
335
348
|
/**
|
|
336
349
|
* A deploy or a check has two subjects: the run itself and the commit it went
|
|
@@ -349,11 +362,12 @@ const twoBlocks = (run, change) => {
|
|
|
349
362
|
const changeRows = live(change);
|
|
350
363
|
return [...runRows, ...(changeRows.length > 0 ? ['', group('Change'), ...changeRows] : [])];
|
|
351
364
|
};
|
|
352
|
-
//
|
|
353
|
-
/**
|
|
354
|
-
// `action`
|
|
355
|
-
// JS,
|
|
356
|
-
//
|
|
365
|
+
// The icon and its rule live in events.ts: the sound depends on it too.
|
|
366
|
+
/** Line 2: the icon sits outside the bold, `<b>Type:</b> action` — the same field, not a special case. */
|
|
367
|
+
// `action` is typed as a string, but it also arrives from `--json` and from
|
|
368
|
+
// direct calls in JS, where there are no types. An empty or missing value
|
|
369
|
+
// produced the row `ℹ️ null` right on the card's second line. An empty string
|
|
370
|
+
// is more honest: the field simply disappears.
|
|
357
371
|
// A link belongs on the NAME of the thing it opens, never on a separate row
|
|
358
372
|
// whose only text is the verb `open`. The owner read `Details: open` under a
|
|
359
373
|
// report and asked what "open" was — the answer is the report itself, which was
|
|
@@ -375,11 +389,12 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
375
389
|
const tail = aside ? ` (${esc(aside)})` : '';
|
|
376
390
|
return line === null ? `${icon} <b>${esc(cap(type))}</b>${tail}` : `${icon} ${line}${tail}`;
|
|
377
391
|
};
|
|
378
|
-
// `workflowUrl ?? url`:
|
|
379
|
-
// `--url` —
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
392
|
+
// `workflowUrl ?? url`: half the senders send the run link under the name
|
|
393
|
+
// `--url` — that name was in the package before and stayed in their calls.
|
|
394
|
+
// The renderer only read `workflowUrl`, so a red card arrived WITH NOT A
|
|
395
|
+
// SINGLE LINK to the logs. Rejecting `--url` would be more honest by name and
|
|
396
|
+
// worse in practice: the intent is unambiguous, and a card with no link is
|
|
397
|
+
// useless exactly when it is needed most.
|
|
383
398
|
/**
|
|
384
399
|
* What to call the thing that ran. The workflow's own name first — it is the
|
|
385
400
|
* only text here that identifies THIS run. Then the caller's own word for the
|
|
@@ -407,13 +422,18 @@ const renderDeploy = (e) => {
|
|
|
407
422
|
const icon = iconFor(e);
|
|
408
423
|
const runUrl = e.workflowUrl ?? e.url;
|
|
409
424
|
return join([
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
425
|
+
// The name of what shipped the deploy sits on the type line. The outcome
|
|
426
|
+
// is already said by the icon and the third tag; there is nothing to
|
|
427
|
+
// repeat in words, and it is the same law a job and a report follow. The
|
|
428
|
+
// `Via` row is gone: it used to carry this same name one floor below.
|
|
413
429
|
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl) ?? e.status, runUrl),
|
|
414
430
|
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
|
|
415
431
|
]);
|
|
416
432
|
};
|
|
433
|
+
const schedule = (expected, lastSeen, lastLabel) => {
|
|
434
|
+
const rows = [field('Expected', expected), field(lastLabel, lastSeen)].filter((r) => r !== null);
|
|
435
|
+
return rows.length > 0 ? ['', group('Schedule'), ...rows] : [];
|
|
436
|
+
};
|
|
417
437
|
const renderJob = (e) => {
|
|
418
438
|
const icon = iconFor(e);
|
|
419
439
|
const hasItems = (e.items ?? []).length > 0;
|
|
@@ -429,12 +449,13 @@ const renderJob = (e) => {
|
|
|
429
449
|
// icon says and what the third tag now says too. The icon table on the
|
|
430
450
|
// catalogue page defines both marks.
|
|
431
451
|
return join([
|
|
432
|
-
typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url),
|
|
452
|
+
typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url, e.aside),
|
|
433
453
|
field('Reason', e.note),
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
454
|
+
// The timetable is a different subject from this event: how often the task
|
|
455
|
+
// owes a sign of life and when it last gave one. It stood in a bare run
|
|
456
|
+
// under `Reason:` and read as more of the same. `Last run` when the task
|
|
457
|
+
// is alive, `Last seen` when it is not — one timestamp, two questions.
|
|
458
|
+
...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
|
|
438
459
|
...labelled(e.stats),
|
|
439
460
|
hasItems ? '' : null,
|
|
440
461
|
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
@@ -459,7 +480,7 @@ const renderReport = (e) => {
|
|
|
459
480
|
// without a word.
|
|
460
481
|
const numbers = labelled(e.lines);
|
|
461
482
|
return join([
|
|
462
|
-
typeLine(iconFor(e), 'Report', e.title, e.url, e.
|
|
483
|
+
typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
|
|
463
484
|
// Rows with no group of their own sit flush against the header instead of
|
|
464
485
|
// forming a separate slab under a blank line. `labelled` puts the blank
|
|
465
486
|
// line before the first group itself, so there is none here.
|
|
@@ -472,7 +493,7 @@ const renderReport = (e) => {
|
|
|
472
493
|
return join([
|
|
473
494
|
// Both analytics jobs send a link to the day's snapshot in docs/. It used to
|
|
474
495
|
// hang off a trailing `Details: open` row; now it is the report's own name.
|
|
475
|
-
typeLine(iconFor(e), 'Report', e.title, e.url, e.
|
|
496
|
+
typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
|
|
476
497
|
// Flush against the header — see the branch above.
|
|
477
498
|
...labelled(e.lines),
|
|
478
499
|
items.length > 0 ? '' : null,
|
|
@@ -498,17 +519,28 @@ const renderCi = (e) => {
|
|
|
498
519
|
// The action is not repeated in words: the icon carries it, and no two actions
|
|
499
520
|
// of one type share an icon.
|
|
500
521
|
const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
|
|
522
|
+
// The people come BEFORE the text, and the text comes only when it is the
|
|
523
|
+
// news. An `assigned` card carries one new fact — who took it — and it used to
|
|
524
|
+
// sit dead last, under the issue's entire description: the owner read a card
|
|
525
|
+
// about someone taking issue #312 and asked who, because he never got that far.
|
|
526
|
+
//
|
|
527
|
+
// The description is the news exactly once, when the thing is opened. On
|
|
528
|
+
// assigned, closed, merged or a review verdict it is text he has already read,
|
|
529
|
+
// and it buries the one line he came for.
|
|
530
|
+
const opening = (action, body) => action === 'opened' ? bodyQuote(body) : null;
|
|
501
531
|
const renderPr = (e) => join([
|
|
502
532
|
typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
|
|
503
|
-
bodyQuote(e.body),
|
|
504
533
|
field('Author', e.author),
|
|
505
|
-
field('Reviewer', e.reviewer)
|
|
534
|
+
field('Reviewer', e.reviewer),
|
|
535
|
+
e.action === 'opened' && e.body ? '' : null,
|
|
536
|
+
opening(e.action, e.body)
|
|
506
537
|
]);
|
|
507
538
|
const renderIssue = (e) => join([
|
|
508
539
|
typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
|
|
509
|
-
bodyQuote(e.body),
|
|
510
540
|
field('Author', e.author),
|
|
511
|
-
field('Assignee', e.assignee)
|
|
541
|
+
field('Assignee', e.assignee),
|
|
542
|
+
e.action === 'opened' && e.body ? '' : null,
|
|
543
|
+
opening(e.action, e.body)
|
|
512
544
|
]);
|
|
513
545
|
// The incident's own title IS line 2, exactly as an issue's is. It used to say
|
|
514
546
|
// the word `open` there — which the 🚨 already says, and no other card repeats
|
|
@@ -555,8 +587,7 @@ const renderHeartbeatMiss = (e) => {
|
|
|
555
587
|
// copy of that watchdog can, and the card it gets must obey the template.
|
|
556
588
|
typeLine(icon, 'Heartbeat', e.job, undefined, action),
|
|
557
589
|
field('Reason', e.note),
|
|
558
|
-
|
|
559
|
-
field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
|
|
590
|
+
...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
|
|
560
591
|
]);
|
|
561
592
|
};
|
|
562
593
|
const RENDERERS = {
|
|
@@ -570,21 +601,22 @@ const RENDERERS = {
|
|
|
570
601
|
session: renderSession,
|
|
571
602
|
heartbeat_miss: renderHeartbeatMiss
|
|
572
603
|
};
|
|
573
|
-
//
|
|
574
|
-
// (
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
604
|
+
// The tag at the top of the card AND the parser's machine key are ONE AND THE
|
|
605
|
+
// SAME value (the owner's decision, 20.08.2026): they used to be two separate
|
|
606
|
+
// representations of one fact (a hyphenated `#ci-arvent` at the bottom, tags
|
|
607
|
+
// typed by hand at the top), and that read as duplication. The separator is
|
|
608
|
+
// an underscore, not a hyphen: a hyphen splits a Telegram hashtag in the
|
|
609
|
+
// middle of a word (`#mac-config` links only as `#mac`), and the tag MUST be
|
|
610
|
+
// clickable — that is exactly the "show this instance's whole history" filter
|
|
611
|
+
// the owner uses in practice.
|
|
580
612
|
export const slug = (raw) => raw
|
|
581
613
|
.toLowerCase()
|
|
582
614
|
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
583
615
|
.replace(/^_+|_+$/g, '')
|
|
584
616
|
.slice(0, 60);
|
|
585
|
-
//
|
|
586
|
-
// `#heartbeat_miss`,
|
|
587
|
-
// (
|
|
617
|
+
// The type tag at the top is not the literal `e.type`: `heartbeat_miss` would
|
|
618
|
+
// read as `#heartbeat_miss`, while the type the owner sees is always just
|
|
619
|
+
// `#heartbeat` (a green and a red card of one kind carry the same type tag).
|
|
588
620
|
const TYPE_TAG = {
|
|
589
621
|
deploy: 'deploy',
|
|
590
622
|
job: 'job',
|
|
@@ -597,12 +629,13 @@ const TYPE_TAG = {
|
|
|
597
629
|
heartbeat_miss: 'heartbeat'
|
|
598
630
|
};
|
|
599
631
|
/**
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
632
|
+
* The instance tag: exactly which concrete event this is (branch,
|
|
633
|
+
* environment, task, number) — the parser uses it to match a 🔴 against a
|
|
634
|
+
* later green card of the SAME instance. An explicit `key` always wins;
|
|
635
|
+
* without one, it is derived from the type's most stable fields (branch/
|
|
636
|
+
* environment outrank the title, because a recurring task's title does not
|
|
637
|
+
* change, while for a report the title is exactly the one stable field it
|
|
638
|
+
* has).
|
|
606
639
|
*/
|
|
607
640
|
export const eventKey = (e) => {
|
|
608
641
|
const fallback = () => {
|
|
@@ -630,10 +663,11 @@ export const eventKey = (e) => {
|
|
|
630
663
|
return e.key ? slug(e.key) : fallback();
|
|
631
664
|
};
|
|
632
665
|
/**
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
666
|
+
* The third tag is the OUTCOME, and it is always there. The owner: "I'm
|
|
667
|
+
* missing a fail tag or something like it, so failures can be grouped and
|
|
668
|
+
* ok can be grouped." One tap in Telegram collects every failure of a
|
|
669
|
+
* project at once, no matter what type it arrived as — a deploy, a check,
|
|
670
|
+
* a scheduled task, an incident.
|
|
637
671
|
*
|
|
638
672
|
* The value comes from the ICON, never from the status word. The icon is
|
|
639
673
|
* already the single source of truth for the sound, and a second list of "what
|
|
@@ -659,33 +693,34 @@ export const OUTCOME_TAG = {
|
|
|
659
693
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)] ?? 'news';
|
|
660
694
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
661
695
|
/**
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
696
|
+
* The tag line for free-form HTML (`sendReport`). The tag is the owner's
|
|
697
|
+
* FILTER, and it has nothing to do with the body's format: a daily report
|
|
698
|
+
* stays free-form text, but stops being the one card with no tags. The key
|
|
699
|
+
* used to hang off the tail as `<i><code>#key</code></i>` — that is the old
|
|
700
|
+
* format, from before tags moved to the first line.
|
|
667
701
|
*/
|
|
668
702
|
export const reportTags = (key) => `#report #${esc(slug(key))}`;
|
|
669
703
|
/**
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
704
|
+
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
705
|
+
* Tags are the FIRST line, added before the cut (not after, as before):
|
|
706
|
+
* they carry both the human filter and the parser's machine key — a card
|
|
707
|
+
* cut without them would be not only unclickable but invisible to the
|
|
708
|
+
* parser on exactly the longest, meaning the most important, messages.
|
|
675
709
|
*/
|
|
676
710
|
export const render = (e) => {
|
|
677
711
|
const renderer = RENDERERS[e.type];
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
// function`.
|
|
712
|
+
// Covers the `--json` path and calls from JS with no types: there `type`
|
|
713
|
+
// is a plain string, and an unknown value crashed the process through
|
|
714
|
+
// `renderer is not a function`. A notification must never crash anything.
|
|
681
715
|
if (typeof renderer !== 'function') {
|
|
682
716
|
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
683
717
|
}
|
|
684
718
|
const tags = tagsLine(e);
|
|
685
|
-
// clampMessage
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
719
|
+
// clampMessage can go past the passed limit for the tail of closing tags
|
|
720
|
+
// and the ellipsis — minus 40 leaves it that margin. Messages already have
|
|
721
|
+
// their own margin (4000 against Telegram's 4096); for a caption the 1024
|
|
722
|
+
// limit is the real one. A card with an attachment is a caption, so the
|
|
723
|
+
// budget is chosen by `path`.
|
|
689
724
|
const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
|
|
690
725
|
return `${tags}\n${clampMessage(renderer(e), budget)}`;
|
|
691
726
|
};
|