@mikitasazan/notify 1.11.0 → 1.12.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/README.md +17 -0
- package/dist/cli-flags.js +2 -1
- package/dist/cli.js +44 -6
- package/dist/events.d.ts +74 -17
- package/dist/lint.js +56 -15
- package/dist/render.d.ts +8 -5
- package/dist/render.js +249 -90
- package/dist/send.d.ts +7 -0
- package/dist/send.js +165 -10
- package/dist/trend.d.ts +9 -6
- package/dist/trend.js +21 -14
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
* after every line.
|
|
21
21
|
*/
|
|
22
22
|
import { ICON, iconFor } from "./events.js";
|
|
23
|
-
/** First letter capitalized, the rest left as is (ga4/GitHub stay themselves). */
|
|
24
23
|
/**
|
|
25
24
|
* A label gets a capital letter — but NOT a name that is deliberately
|
|
26
25
|
* written lowercase: `iOS` was turning into `IOS`. The signal is a capital
|
|
@@ -52,7 +51,7 @@ export const esc = (v) => String(v ?? '')
|
|
|
52
51
|
* Telegram replies `400 can't parse entities`, and we treat a 4xx as a
|
|
53
52
|
* permanent error and do not retry — the message disappeared for good.
|
|
54
53
|
*/
|
|
55
|
-
export const clampMessage = (text, limit = 4000) => {
|
|
54
|
+
export const clampMessage = (text, limit = 4000, marker = '…') => {
|
|
56
55
|
if (text.length <= limit) {
|
|
57
56
|
return text;
|
|
58
57
|
}
|
|
@@ -61,8 +60,14 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
61
60
|
// Cut on a line boundary — only if that keeps most of the content.
|
|
62
61
|
let end = lastBreak > limit * 0.6 ? lastBreak : limit;
|
|
63
62
|
// Never cut inside `<...>` or inside `&...;` — otherwise the markup breaks.
|
|
63
|
+
// The closing `>` must be searched for only up to `end`, not across all of
|
|
64
|
+
// `cut`: a `>` that belongs to a LATER, already-doomed part of the message
|
|
65
|
+
// (still inside `cut` because `cut` runs to `limit`, past `end`) used to
|
|
66
|
+
// read as "this tag is closed" and let a tag get cut mid-attribute anyway —
|
|
67
|
+
// found by GLM review, reproduces with a long `href` that straddles the
|
|
68
|
+
// line-boundary cut point.
|
|
64
69
|
const openTag = cut.lastIndexOf('<', end - 1);
|
|
65
|
-
if (openTag !== -1 && cut.indexOf('>'
|
|
70
|
+
if (openTag !== -1 && cut.slice(openTag, end).indexOf('>') === -1) {
|
|
66
71
|
end = openTag;
|
|
67
72
|
}
|
|
68
73
|
const amp = cut.lastIndexOf('&', end - 1);
|
|
@@ -103,7 +108,12 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
103
108
|
.reverse()
|
|
104
109
|
.map((t) => `</${t}>`)
|
|
105
110
|
.join('');
|
|
106
|
-
|
|
111
|
+
// The marker ANNOUNCES the cut instead of hiding it (v2.1): the caller
|
|
112
|
+
// reserves the marker's length out of `limit` before calling, so the
|
|
113
|
+
// marker itself can never be the thing that pushes the message over
|
|
114
|
+
// Telegram's hard cap — a free addition on top of 4000/4096 plus a long
|
|
115
|
+
// path was measured to earn a 400, and a 4xx is never retried.
|
|
116
|
+
return `${body}${tail}\n${marker}`;
|
|
107
117
|
};
|
|
108
118
|
// Only the first line: a field is single-line by contract (commit, branch,
|
|
109
119
|
// author, a stat), not a place for a paragraph. A live case (18.08): a CI card
|
|
@@ -124,6 +134,21 @@ const firstLine = (value) => {
|
|
|
124
134
|
* value serializes as `null`, not as a missing key.
|
|
125
135
|
*/
|
|
126
136
|
const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
|
|
137
|
+
/**
|
|
138
|
+
* A Reason-shaped field: one line stays an ordinary field, several lines
|
|
139
|
+
* become a captioned quote — NOTHING is cut to the first line any more.
|
|
140
|
+
* `field`'s silent `firstLine` on a multi-line reason was the v1 contract,
|
|
141
|
+
* and it gutted every card whose failure did not fit one line (scp retries,
|
|
142
|
+
* a three-line diagnosis): the owner saw `Connection timed…` and nothing
|
|
143
|
+
* else. Confirmed live on four cards in the 14-day sweep, fixed in v2.1.
|
|
144
|
+
*/
|
|
145
|
+
const reason = (label, value) => {
|
|
146
|
+
if (value === undefined || value === null || value === '') {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const text = String(value);
|
|
150
|
+
return text.includes('\n') ? quoted(label, text) : field(label, text);
|
|
151
|
+
};
|
|
127
152
|
/**
|
|
128
153
|
* An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
|
|
129
154
|
* one exists, otherwise the plain text of the same field — an identifier
|
|
@@ -140,19 +165,43 @@ const fieldLink = (label, url, text) => {
|
|
|
140
165
|
? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
|
|
141
166
|
: field(label, text);
|
|
142
167
|
};
|
|
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.
|
|
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.
|
|
153
|
-
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
154
168
|
/** A monospace field — a path/command to copy, not a link. */
|
|
155
169
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
170
|
+
/**
|
|
171
|
+
* A person field — Author, Assignee, Reviewer. Every one of them is a
|
|
172
|
+
* GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
|
|
173
|
+
* on the GitHub API object), and every GitHub login is a profile at one fixed
|
|
174
|
+
* address: `github.com/<login>`. The owner: "автор должен вести на страницу
|
|
175
|
+
* автора" — a name is an identifier like a commit hash or a PR number, and
|
|
176
|
+
* every other identifier on the card is a link.
|
|
177
|
+
*/
|
|
178
|
+
// `firstLine`, same as `fieldLink`: a login with an embedded `\n` (found by
|
|
179
|
+
// Codex/GLM review) would otherwise land a raw newline inside the `href`
|
|
180
|
+
// itself, not just the link text — every other field guards against a
|
|
181
|
+
// multi-line value, this one didn't.
|
|
182
|
+
const fieldPerson = (label, login) => {
|
|
183
|
+
if (!login) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const oneLine = firstLine(login);
|
|
187
|
+
return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* `Actor` on a CI card is a Telegram handle, not a GitHub login
|
|
191
|
+
* (`nightly.yml`, step "Кто чинит" — "по «@chelsnebes» приходит уведомление
|
|
192
|
+
* тому, кто чинит, по «mikitasazan» — нет"), so it links to Telegram, not
|
|
193
|
+
* GitHub: `github.com/@chelsnebes` would open a page that does not exist.
|
|
194
|
+
* Telegram DOES auto-link a bare `@handle` on its own, but the owner asked
|
|
195
|
+
* for an explicit link like every other identifier on the card, not an
|
|
196
|
+
* implicit one riding on a client behavior he cannot see from here.
|
|
197
|
+
*/
|
|
198
|
+
const fieldTelegram = (label, handle) => {
|
|
199
|
+
if (!handle) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const oneLine = firstLine(handle);
|
|
203
|
+
return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(oneLine.replace(/^@/, ''))}">${esc(oneLine)}</a>`;
|
|
204
|
+
};
|
|
156
205
|
/**
|
|
157
206
|
* A row that asks something FROM THE OWNER, rather than reports a fact. It
|
|
158
207
|
* already stood last, set off by a blank line, and still read as an
|
|
@@ -178,13 +227,25 @@ const fieldRun = (value, why) => {
|
|
|
178
227
|
};
|
|
179
228
|
/** A group heading: italic + underline, no bold, no colon. */
|
|
180
229
|
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
181
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* An item inside a group: `<b>label:</b> <a>text</a>` — or a plain
|
|
232
|
+
* bulleted/numbered row with no label. `facts`, when the item carries them,
|
|
233
|
+
* print as indented `label: value` rows underneath — a nested list, one
|
|
234
|
+
* item with several facts of its own, the way a search query has both a
|
|
235
|
+
* click count and a position.
|
|
236
|
+
*/
|
|
182
237
|
const groupItem = (it, index, numbered) => {
|
|
183
238
|
const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
|
|
184
|
-
|
|
185
|
-
|
|
239
|
+
const head = it.label
|
|
240
|
+
? `<b>${esc(cap(it.label))}:</b> ${linked}`
|
|
241
|
+
: numbered
|
|
242
|
+
? `${index + 1}. ${linked}`
|
|
243
|
+
: `• ${linked}`;
|
|
244
|
+
if (!it.facts || it.facts.length === 0) {
|
|
245
|
+
return head;
|
|
186
246
|
}
|
|
187
|
-
|
|
247
|
+
const sub = it.facts.map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
|
|
248
|
+
return [head, ...sub].join('\n');
|
|
188
249
|
};
|
|
189
250
|
// A long explanation (a note, incident details) — as a quote: in Telegram
|
|
190
251
|
// that is a bar on the left and a light indent, reading as "details," not as
|
|
@@ -206,15 +267,16 @@ const note = (text) => {
|
|
|
206
267
|
* the card says it nowhere. The caption stands on its own line, because
|
|
207
268
|
* the text itself does not fit in a field: a field holds one line and cuts
|
|
208
269
|
* it.
|
|
270
|
+
*
|
|
271
|
+
* The caption is a bold field label with nothing after the colon, not the
|
|
272
|
+
* group() heading (italic-underline). It was the group heading first, and
|
|
273
|
+
* the owner read that and said it did not look like a category — correctly:
|
|
274
|
+
* a group is a heading over a LIST, and this caption sits over one quote,
|
|
275
|
+
* never more than one. A bold label with no value on the line is the same
|
|
276
|
+
* shape `Title:` already has right before a PR's body — the reader has
|
|
277
|
+
* already seen this exact pattern mean "what follows is quoted text."
|
|
209
278
|
*/
|
|
210
|
-
|
|
211
|
-
* A quote that needs saying what it is. The heading is a GROUP heading — the
|
|
212
|
-
* same italic-underline every other card uses over a block — not a bold field
|
|
213
|
-
* label: a bold label means `label: value` on one line, and using it here made
|
|
214
|
-
* the session card the only one whose block was titled a third way. The owner
|
|
215
|
-
* read the card and asked where its group was.
|
|
216
|
-
*/
|
|
217
|
-
const quoted = (label, text) => text ? `${group(label)}\n${note(text)}` : null;
|
|
279
|
+
const quoted = (label, text) => text ? `<b>${esc(cap(label))}:</b>\n${note(text)}` : null;
|
|
218
280
|
/**
|
|
219
281
|
* Assembles the card. A blank line here marks a block change, not
|
|
220
282
|
* indentation: two in a row mean an empty block, a leading one means a
|
|
@@ -421,14 +483,12 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
421
483
|
/**
|
|
422
484
|
* What to call the thing that ran. The workflow's own name first — it is the
|
|
423
485
|
* only text here that identifies THIS run. Then the caller's own word for the
|
|
424
|
-
* mechanism (`manual, from the Mac`).
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
* GitHub Action always fills the workflow name, and the hand-run scripts send
|
|
429
|
-
* no run link at all.
|
|
486
|
+
* mechanism (`manual, from the Mac`). No third fallback: a row that says
|
|
487
|
+
* nothing distinctive is worse than not printing a name at all, and
|
|
488
|
+
* `typeLine` already handles the case with no name — a bare `<b>Deploy</b>`,
|
|
489
|
+
* still linked when a run URL exists.
|
|
430
490
|
*/
|
|
431
|
-
const mechanism = (workflowName, via
|
|
491
|
+
const mechanism = (workflowName, via) => workflowName ?? via;
|
|
432
492
|
// The name of what ran sits WITH the type line, not eight lines below it.
|
|
433
493
|
// `Deploy: fail` and `by what means it ran` answer one question, and the owner
|
|
434
494
|
// read the two rows as unrelated things. It used to be one fact split in two:
|
|
@@ -443,14 +503,19 @@ const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
|
|
|
443
503
|
// unlinked, because a hand deploy has no run to open.
|
|
444
504
|
const renderDeploy = (e) => {
|
|
445
505
|
const icon = iconFor(e);
|
|
446
|
-
const runUrl = e.workflowUrl ?? e.url;
|
|
447
506
|
return join([
|
|
448
|
-
// The name of what shipped the deploy sits on the type line
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
507
|
+
// The name of what shipped the deploy sits on the type line, the outcome
|
|
508
|
+
// in parens beside it — icon and tag alone were judged, live, not to be
|
|
509
|
+
// enough: a plain 🔴 next to a workflow name still read as "something
|
|
510
|
+
// happened," not "it failed," on a screen small enough to lose the color.
|
|
511
|
+
// The `Via` row is gone: it used to carry this same name one floor below.
|
|
512
|
+
// The run URL is gone from this line too — it is the `Source:` row now.
|
|
513
|
+
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
|
|
514
|
+
...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
515
|
+
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
516
|
+
fieldPerson('Author', e.commitAuthor),
|
|
517
|
+
bodyQuote(e.commitBody)
|
|
518
|
+
])
|
|
454
519
|
]);
|
|
455
520
|
};
|
|
456
521
|
const schedule = (expected, lastSeen, lastLabel) => {
|
|
@@ -472,27 +537,30 @@ const renderJob = (e) => {
|
|
|
472
537
|
// icon says and what the third tag now says too. The icon table on the
|
|
473
538
|
// catalogue page defines both marks.
|
|
474
539
|
return join([
|
|
475
|
-
|
|
476
|
-
|
|
540
|
+
// The URL moved off the type line into the `Source:` row of the pointer
|
|
541
|
+
// block (v2.1, rule S): the owner asked for a pointer he can SEE, and a
|
|
542
|
+
// link riding invisibly on the name is not one.
|
|
543
|
+
typeLine(icon, 'Job', e.job, undefined, e.aside),
|
|
544
|
+
reason('Reason', e.note),
|
|
545
|
+
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
477
546
|
// The timetable is a different subject from this event: how often the task
|
|
478
547
|
// owes a sign of life and when it last gave one. It stood in a bare run
|
|
479
548
|
// under `Reason:` and read as more of the same. `Last run` when the task
|
|
480
549
|
// is alive, `Last seen` when it is not — one timestamp, two questions.
|
|
481
550
|
...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
|
|
482
551
|
...labelled(e.stats),
|
|
552
|
+
e.detail ? '' : null,
|
|
553
|
+
quoted(e.detailLabel ?? 'Detail', e.detail),
|
|
483
554
|
hasItems ? '' : null,
|
|
484
555
|
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
485
556
|
// list, so playhub's daily card of newly published games was headed
|
|
486
557
|
// "Disabled workflows".
|
|
487
558
|
disabledList ? group('Disabled workflows') : null,
|
|
488
559
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
489
|
-
e.command
|
|
490
|
-
fieldCode('Log', e.logs),
|
|
560
|
+
e.command ? '' : null,
|
|
491
561
|
...fieldRun(e.command, e.commandNote)
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// written twice, and it stood BELOW the `To do:` command, which is the
|
|
495
|
-
// last thing the card is supposed to say.
|
|
562
|
+
// `Log:` left this body for the pointer block that `render` appends to
|
|
563
|
+
// every card — the block a cut can never take.
|
|
496
564
|
]);
|
|
497
565
|
};
|
|
498
566
|
const renderReport = (e) => {
|
|
@@ -503,7 +571,7 @@ const renderReport = (e) => {
|
|
|
503
571
|
// without a word.
|
|
504
572
|
const numbers = labelled(e.lines);
|
|
505
573
|
return join([
|
|
506
|
-
typeLine(iconFor(e), 'Report', e.title,
|
|
574
|
+
typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
|
|
507
575
|
// Rows with no group of their own sit flush against the header instead of
|
|
508
576
|
// forming a separate slab under a blank line. `labelled` puts the blank
|
|
509
577
|
// line before the first group itself, so there is none here.
|
|
@@ -514,25 +582,37 @@ const renderReport = (e) => {
|
|
|
514
582
|
}
|
|
515
583
|
const items = bullets(e.items, false);
|
|
516
584
|
return join([
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
|
|
585
|
+
// The day's snapshot link is the `Source:` row now, same as every URL.
|
|
586
|
+
typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
|
|
520
587
|
// Flush against the header — see the branch above.
|
|
521
588
|
...labelled(e.lines),
|
|
522
589
|
items.length > 0 ? '' : null,
|
|
523
590
|
...items
|
|
524
591
|
]);
|
|
525
592
|
};
|
|
526
|
-
// Same law as the deploy card
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
593
|
+
// Same law as the deploy card: what ran joins the type line itself and
|
|
594
|
+
// carries the link to its run — `CI: nightly`, not a separate `Check:`/`Via:`
|
|
595
|
+
// row. There is no separate label here at all: on deploy the mechanism
|
|
596
|
+
// answers by what means the code was shipped, on CI it answers WHICH gate
|
|
597
|
+
// spoke (`nightly`, `Quality`) — but both are the name on the type line.
|
|
530
598
|
const renderCi = (e) => {
|
|
531
599
|
const icon = iconFor(e);
|
|
532
|
-
const runUrl = e.workflowUrl ?? e.url;
|
|
533
600
|
return join([
|
|
534
|
-
|
|
535
|
-
|
|
601
|
+
// The run URL is the `Source:` row now, not an invisible link on the name.
|
|
602
|
+
typeLine(icon, 'CI', mechanism(e.workflowName, undefined), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
|
|
603
|
+
// `Actor` used to be read as "who wrote the commit," and on most runs it
|
|
604
|
+
// is — `github.actor` for a push IS the person who pushed. It stops being
|
|
605
|
+
// that on a scheduled run: arvent's nightly rewrites it to whoever is on
|
|
606
|
+
// duty to fix a red run, which can be someone other than the commit's
|
|
607
|
+
// author. So Actor answers "who is responsible for this run," `Author`
|
|
608
|
+
// below the commit answers "who wrote this code" — two different people
|
|
609
|
+
// on a nightly card, the same person everywhere else.
|
|
610
|
+
...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
611
|
+
fieldTelegram('Actor', e.actor),
|
|
612
|
+
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
613
|
+
fieldPerson('Author', e.commitAuthor),
|
|
614
|
+
bodyQuote(e.commitBody)
|
|
615
|
+
])
|
|
536
616
|
]);
|
|
537
617
|
};
|
|
538
618
|
// A pull request and an issue are identified the way GitHub itself identifies
|
|
@@ -548,22 +628,42 @@ const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
|
|
|
548
628
|
// about someone taking issue #312 and asked who, because he never got that far.
|
|
549
629
|
//
|
|
550
630
|
// The description is the news exactly once, when the thing is opened. On
|
|
551
|
-
// assigned, closed
|
|
552
|
-
//
|
|
553
|
-
|
|
631
|
+
// assigned, closed or merged it is text he has already read, and it buries
|
|
632
|
+
// the one line he came for.
|
|
633
|
+
//
|
|
634
|
+
// A review verdict is the one exception: on `approved`/`changes_requested`
|
|
635
|
+
// `body` is not the PR's description any more — the sender puts the
|
|
636
|
+
// REVIEWER'S OWN comment there, which is new text he has not seen. "Verdict:
|
|
637
|
+
// changes_requested, then nothing" was the owner's complaint: a verdict with
|
|
638
|
+
// no comment attached said less than the review itself did.
|
|
639
|
+
const VERDICT = new Set(['approved', 'changes_requested']);
|
|
640
|
+
const opening = (action, body) => action === 'opened' || VERDICT.has(action) ? bodyQuote(body) : null;
|
|
641
|
+
// The body is what the title stands for — it sits directly under the name,
|
|
642
|
+
// with nothing between them. The people come after, consolidated in one
|
|
643
|
+
// place, never splitting the title from what it names: the owner on the
|
|
644
|
+
// old order, title then Author then Assignee then finally the body — "why
|
|
645
|
+
// does the assignee cut apart what should be inseparable?"
|
|
554
646
|
const renderPr = (e) => join([
|
|
555
|
-
typeLine(iconFor(e), 'PR', named(e.number, e.title)
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
647
|
+
typeLine(iconFor(e), 'PR', named(e.number, e.title)),
|
|
648
|
+
opening(e.action, e.body),
|
|
649
|
+
(e.action === 'opened' || VERDICT.has(e.action)) && e.body ? '' : null,
|
|
650
|
+
fieldPerson('Author', e.author),
|
|
651
|
+
fieldPerson('Reviewer', e.reviewer)
|
|
560
652
|
]);
|
|
653
|
+
// The issue's body prints ONLY on `opened` — the same law the PR card
|
|
654
|
+
// follows. History of this line: hidden → shown everywhere (the owner's call
|
|
655
|
+
// of 26.08.2026, "inconsistent with the same card showing it moments
|
|
656
|
+
// earlier") → hidden again on 31.08.2026, when the owner, shown the live
|
|
657
|
+
// duplicate ("the full body arrives a second time in one day"), delegated
|
|
658
|
+
// the call ("сделай как лучше по оптимизации и простоте") and approved the
|
|
659
|
+
// short assigned card in the v2.1 mockups. On assigned/closed the card is
|
|
660
|
+
// the one new fact plus the `Source:` link to the full text.
|
|
561
661
|
const renderIssue = (e) => join([
|
|
562
|
-
typeLine(iconFor(e), 'Issue', named(e.number, e.title)
|
|
563
|
-
|
|
564
|
-
field('Assignee', e.assignee),
|
|
662
|
+
typeLine(iconFor(e), 'Issue', named(e.number, e.title)),
|
|
663
|
+
e.action === 'opened' ? bodyQuote(e.body) : null,
|
|
565
664
|
e.action === 'opened' && e.body ? '' : null,
|
|
566
|
-
|
|
665
|
+
fieldPerson('Author', e.author),
|
|
666
|
+
fieldPerson('Assignee', e.assignee)
|
|
567
667
|
]);
|
|
568
668
|
// The incident's own title IS line 2, exactly as an issue's is. It used to say
|
|
569
669
|
// the word `open` there — which the 🚨 already says, and no other card repeats
|
|
@@ -573,12 +673,17 @@ const renderIssue = (e) => join([
|
|
|
573
673
|
// log path). It used to go through `field`, which keeps only the first line, so
|
|
574
674
|
// every alarm this package ever sent arrived gutted. It is quoted now, the same
|
|
575
675
|
// shape a commit body takes.
|
|
576
|
-
const renderIncident = (e) =>
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
676
|
+
const renderIncident = (e) => {
|
|
677
|
+
const findings = bullets(e.items, false);
|
|
678
|
+
return join([
|
|
679
|
+
typeLine(iconFor(e), 'Incident', e.title),
|
|
680
|
+
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
681
|
+
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
682
|
+
findings.length > 0 ? '' : null,
|
|
683
|
+
...findings
|
|
684
|
+
// `Log:` moved to the pointer block `render` appends — see renderJob.
|
|
685
|
+
]);
|
|
686
|
+
};
|
|
582
687
|
// A session in trouble. Same law as every other card: identifier first, then
|
|
583
688
|
// the facts as fields, then his own words as a quote — never as a field, which
|
|
584
689
|
// keeps one line and clipped the name of the very session the card is about.
|
|
@@ -593,7 +698,8 @@ const renderSession = (e) => join([
|
|
|
593
698
|
// opened a block that had no heading. Facts about the session touch the
|
|
594
699
|
// line that names it, the way they do on every job card.
|
|
595
700
|
field('Project', e.workdir),
|
|
596
|
-
|
|
701
|
+
reason('Reason', e.reason),
|
|
702
|
+
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
597
703
|
e.opened ? '' : null,
|
|
598
704
|
quoted('Opened with', e.opened),
|
|
599
705
|
e.command ? '' : null,
|
|
@@ -718,12 +824,62 @@ export const OUTCOME_TAG = {
|
|
|
718
824
|
};
|
|
719
825
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
720
826
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
827
|
+
/**
|
|
828
|
+
* Rule S (v2.1): the card's last block says where to verify it. `Source:` is
|
|
829
|
+
* a hyperlink when the event has a canonical URL; `Check:` is a local
|
|
830
|
+
* command; `Log:` is a path and only ever an ADDITION — a path cannot be
|
|
831
|
+
* tapped, only copied. The link text is a short English noun naming what
|
|
832
|
+
* opens, never the click.
|
|
833
|
+
*/
|
|
834
|
+
const SOURCE_NAME = {
|
|
835
|
+
deploy: 'workflow run',
|
|
836
|
+
ci: 'workflow run',
|
|
837
|
+
job: 'workflow run',
|
|
838
|
+
report: 'report',
|
|
839
|
+
pr: 'pull request',
|
|
840
|
+
issue: 'issue',
|
|
841
|
+
incident: 'details',
|
|
842
|
+
session: 'details',
|
|
843
|
+
heartbeat_miss: 'details'
|
|
844
|
+
};
|
|
845
|
+
const sourceUrl = (e) => {
|
|
846
|
+
const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
|
|
847
|
+
const url = 'url' in e ? e.url : undefined;
|
|
848
|
+
return wf ?? url;
|
|
849
|
+
};
|
|
850
|
+
const pointerBlock = (e) => {
|
|
851
|
+
const url = sourceUrl(e);
|
|
852
|
+
const logs = 'logs' in e ? e.logs : undefined;
|
|
853
|
+
const rows = [
|
|
854
|
+
fieldCode('Log', logs),
|
|
855
|
+
fieldCode('Check', e.check),
|
|
856
|
+
url ? `<b>Source:</b> <a href="${esc(url)}">${esc(SOURCE_NAME[e.type] ?? 'source')}</a>` : null
|
|
857
|
+
].filter((r) => r !== null);
|
|
858
|
+
return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
|
|
859
|
+
};
|
|
860
|
+
/**
|
|
861
|
+
* The line that stands in for what the cut removed. It rides INSIDE the
|
|
862
|
+
* budget (the caller subtracts its length before clamping), so announcing
|
|
863
|
+
* the cut can never itself overflow the limit — with an attachment the
|
|
864
|
+
* limit is 1024, and the old flat 40-character margin did not fit a marker
|
|
865
|
+
* plus a path.
|
|
866
|
+
*/
|
|
867
|
+
const cutMarker = (e) => {
|
|
868
|
+
if (e.path) {
|
|
869
|
+
return '⋯ cut, full text attached';
|
|
870
|
+
}
|
|
871
|
+
const logs = 'logs' in e ? e.logs : undefined;
|
|
872
|
+
return logs ? `⋯ cut, full: <code>${esc(logs)}</code>` : '⋯ cut';
|
|
873
|
+
};
|
|
721
874
|
/**
|
|
722
875
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
876
|
+
*
|
|
877
|
+
* Assembly is TAIL-FIRST (v2.1): the parts that must survive any cut — the
|
|
878
|
+
* tag line (the human filter and the parser's machine key), the pointer
|
|
879
|
+
* block (`Log`/`Check`/`Source`) and the cut marker — are measured before
|
|
880
|
+
* the body is clamped, and the body gets what is left. Under the old order
|
|
881
|
+
* the pointer was part of the body, so the longest cards lost exactly the
|
|
882
|
+
* line saying where to look.
|
|
727
883
|
*/
|
|
728
884
|
export const render = (e) => {
|
|
729
885
|
const renderer = RENDERERS[e.type];
|
|
@@ -734,11 +890,14 @@ export const render = (e) => {
|
|
|
734
890
|
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
735
891
|
}
|
|
736
892
|
const tags = tagsLine(e);
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
893
|
+
const pointer = pointerBlock(e);
|
|
894
|
+
const marker = cutMarker(e);
|
|
895
|
+
// clampMessage can go past the passed limit for the tail of closing tags —
|
|
896
|
+
// minus 40 leaves it that margin. Messages already have their own margin
|
|
897
|
+
// (4000 against Telegram's 4096); for a caption the 1024 limit is the real
|
|
898
|
+
// one. A card with an attachment is a caption, so the budget is chosen by
|
|
899
|
+
// `path`.
|
|
900
|
+
const limit = e.path ? 1024 : 4000;
|
|
901
|
+
const budget = Math.max(64, limit - tags.length - pointer.length - marker.length - 42);
|
|
902
|
+
return `${tags}\n${clampMessage(renderer(e), budget, marker)}${pointer}`;
|
|
744
903
|
};
|
package/dist/send.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import type { NotifyEvent } from './events.ts';
|
|
2
2
|
export type SendResult = 'sent' | 'skipped' | 'failed';
|
|
3
|
+
/** Exported for tests only — the time is injectable so day counting is provable. */
|
|
4
|
+
export declare const dedupe: (e: NotifyEvent, now?: number) => {
|
|
5
|
+
action: "send" | "suppress";
|
|
6
|
+
stillRed?: number;
|
|
7
|
+
};
|
|
8
|
+
/** Exported for tests: the watchdog's own card must provably pass the lint. */
|
|
9
|
+
export declare const brokenCardEvent: (e: NotifyEvent, faults: string[], offenderHtml: string) => NotifyEvent;
|
|
3
10
|
export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
|