@mikitasazan/notify 1.11.1 → 1.12.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/README.md +17 -0
- package/dist/cli-flags.js +2 -1
- package/dist/cli.js +24 -0
- package/dist/events.d.ts +24 -0
- package/dist/lint.js +56 -15
- package/dist/render.d.ts +8 -5
- package/dist/render.js +124 -45
- package/dist/send.d.ts +7 -0
- package/dist/send.js +172 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,6 +158,23 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
158
158
|
никогда, только новый тип события**. Тогда старый вызывающий код и новый
|
|
159
159
|
пакет всегда совместимы.
|
|
160
160
|
|
|
161
|
+
## Тег сменился 18.08.2026 у шести отправителей
|
|
162
|
+
|
|
163
|
+
Коммит `85564fc` в mac-config дал явный `--key` шести местам вызова, которые
|
|
164
|
+
раньше отправляли карточку БЕЗ ключа — тег брался из `--job`/`--title`
|
|
165
|
+
автоматически (`slug()`, см. `src/render.ts`). Без этой таблицы старую
|
|
166
|
+
красную карточку в Telegram не найти поиском по новому тегу — она там под
|
|
167
|
+
старым.
|
|
168
|
+
|
|
169
|
+
| Отправитель | Старый тег (авто из русского названия) | Новый тег (из `--key`) |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `arvent-eval-report.sh`, отчёт по эвалу | `#eval_качество_ответов_бота` | `#arvent_eval` |
|
|
172
|
+
| `arvent-eval-report.sh`, файл диалогов | нет — шёл мимо пакета, сырым `sendDocument` Bot API | `#arvent_eval_dialogues` |
|
|
173
|
+
| `daily-digest.sh`, дайджест задач (`job`) | `#дайджест_задач` | `#daily_digest` |
|
|
174
|
+
| `daily-digest.sh`, дайджест задач (`report`) | `#дайджест_задач` (то же слово, другой тип-тег: `#report` вместо `#job`) | `#daily_digest` |
|
|
175
|
+
| `daily-digest.sh`, отключённые Actions | `#github_actions_выключены` | `#actions_off` |
|
|
176
|
+
| `daily-digest.sh`, Config doctor | `#config_doctor` | `#config_doctor` — не изменился: `slug()` приводит и старое, и новое к одной строке |
|
|
177
|
+
|
|
161
178
|
## Чего в пакете нет и почему
|
|
162
179
|
|
|
163
180
|
- Очереди, брокера, демона — десятки сообщений в день, ретрай в памяти
|
package/dist/cli-flags.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
13
|
'id', 'opened', 'reason', 'workdir',
|
|
14
|
-
'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url',
|
|
14
|
+
'check', 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url',
|
|
15
|
+
'detail', 'detail-label',
|
|
15
16
|
'expected', 'filename', 'item',
|
|
16
17
|
'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
17
18
|
'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
package/dist/cli.js
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
25
|
import { KNOWN_FLAGS } from "./cli-flags.js";
|
|
26
26
|
import { render } from "./render.js";
|
|
27
|
+
import { lintCard } from "./lint.js";
|
|
27
28
|
import { notify } from "./send.js";
|
|
29
|
+
import { ROUTES } from "./routes.js";
|
|
28
30
|
import { setupTopic } from "./setup.js";
|
|
29
31
|
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
30
32
|
/**
|
|
@@ -41,6 +43,23 @@ const log = (msg) => console.error(`[notify] ${msg}`);
|
|
|
41
43
|
const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
|
|
42
44
|
const args = process.argv.slice(2);
|
|
43
45
|
const command = args[0];
|
|
46
|
+
// `lint-text` and `routes` exist so the nightly audit asks the PACKAGE
|
|
47
|
+
// instead of keeping its own copy of the rules and the routing table — the
|
|
48
|
+
// copies had already drifted twice.
|
|
49
|
+
if (command === 'lint-text') {
|
|
50
|
+
// The finished card HTML on stdin; every fault on stdout, one per line.
|
|
51
|
+
// Empty output means the card obeys the standard. Exit code stays 0 —
|
|
52
|
+
// the CLI-wide contract.
|
|
53
|
+
const text = readFileSync(0, 'utf-8').replace(/\n$/, '');
|
|
54
|
+
for (const fault of lintCard(text)) {
|
|
55
|
+
process.stdout.write(`${fault}\n`);
|
|
56
|
+
}
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
if (command === 'routes') {
|
|
60
|
+
process.stdout.write(`${JSON.stringify(ROUTES, null, 2)}\n`);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
44
63
|
if (command === 'setup') {
|
|
45
64
|
const [, chatId, projectKey] = args;
|
|
46
65
|
if (!chatId || !projectKey) {
|
|
@@ -261,6 +280,8 @@ else {
|
|
|
261
280
|
command: one('command'),
|
|
262
281
|
commandNote: one('command-note'),
|
|
263
282
|
logs: one('logs'),
|
|
283
|
+
detail: one('detail'),
|
|
284
|
+
detailLabel: one('detail-label'),
|
|
264
285
|
workflowUrl: one('workflow-url'),
|
|
265
286
|
workflowName: one('workflow-name'),
|
|
266
287
|
url: one('url')
|
|
@@ -390,6 +411,9 @@ if (event) {
|
|
|
390
411
|
// --path is applicable to any type too, for the same reason --key is.
|
|
391
412
|
event.path = one('path') ?? event.path;
|
|
392
413
|
event.filename = one('filename') ?? event.filename;
|
|
414
|
+
// --check applies to any type (rule S): the verification command for a
|
|
415
|
+
// card whose event has no canonical URL.
|
|
416
|
+
event.check = one('check') ?? event.check;
|
|
393
417
|
}
|
|
394
418
|
if (event?.filename && !event.path) {
|
|
395
419
|
parseErrors.push('--filename: given without --path, so there is no file to name');
|
package/dist/events.d.ts
CHANGED
|
@@ -37,6 +37,18 @@ type Keyed = {
|
|
|
37
37
|
path?: string;
|
|
38
38
|
/** The file's name in the chat; defaults to the name from `path`. */
|
|
39
39
|
filename?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The verification command — `Check:` in the card's last block, monospaced
|
|
42
|
+
* and tap-to-copy. The standard (v2.1, rule S) wants every card to say
|
|
43
|
+
* where to verify it: a `Source:` link when the event has a canonical URL,
|
|
44
|
+
* this command when the event is local. Usually `config jobs --log <key>`.
|
|
45
|
+
*/
|
|
46
|
+
check?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Set by the send-side suppression, never by a caller: which day of the
|
|
49
|
+
* same unresolved failure this is. Renders as `Still red: day N`.
|
|
50
|
+
*/
|
|
51
|
+
stillRed?: number;
|
|
40
52
|
};
|
|
41
53
|
/**
|
|
42
54
|
* A list item inside a message: a task from a digest, a failed check, a
|
|
@@ -172,8 +184,20 @@ export type NotifyEvent = Keyed & (
|
|
|
172
184
|
* A local log path — monospaced, not a link, same as on an incident.
|
|
173
185
|
* It used to be glued onto the end of the reason sentence behind a
|
|
174
186
|
* colon, which is what made a red card read as one long run-on line.
|
|
187
|
+
* Under rule S it is an ADDITION to `check`/a link, never the card's
|
|
188
|
+
* only pointer — a path cannot be tapped, only copied.
|
|
175
189
|
*/
|
|
176
190
|
logs?: string;
|
|
191
|
+
/**
|
|
192
|
+
* A multi-line quoted block with its own caption (`detailLabel`) — the
|
|
193
|
+
* shape the watchdog uses to show the offending card's first lines
|
|
194
|
+
* under `Offender:`. Generic on purpose: any job with a verbatim
|
|
195
|
+
* excerpt to show (someone else's text, not the sender's own words)
|
|
196
|
+
* uses this instead of stuffing it into `note`.
|
|
197
|
+
*/
|
|
198
|
+
detail?: string;
|
|
199
|
+
/** The caption over `detail`; defaults to `Detail`. */
|
|
200
|
+
detailLabel?: string;
|
|
177
201
|
workflowUrl?: string;
|
|
178
202
|
/** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
|
|
179
203
|
workflowName?: string;
|
package/dist/lint.js
CHANGED
|
@@ -32,8 +32,12 @@ const NOT_A_NAME = new Set([
|
|
|
32
32
|
]);
|
|
33
33
|
/** The five words the third tag is allowed to be, and there is no sixth. */
|
|
34
34
|
const OUTCOMES = new Set(['ok', 'fail', 'off', 'unknown', 'info']);
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Labels the template retired. Each one used to say what a neighbour said.
|
|
37
|
+
* `Check` LEFT this list in v2.1: rule S brought it back as the standard
|
|
38
|
+
* verification-command row. `Logs` stays retired — the new spelling is `Log`.
|
|
39
|
+
*/
|
|
40
|
+
const RETIRED = ['Title', 'Number', 'State', 'Via', 'Logs', 'Task', 'Id', 'Period'];
|
|
37
41
|
/**
|
|
38
42
|
* Reads a finished card and returns what is wrong with it, in the owner's
|
|
39
43
|
* terms. An empty array means the card obeys the standard.
|
|
@@ -45,8 +49,23 @@ export const lintCard = (html) => {
|
|
|
45
49
|
if (tags.length !== 3 || !tags.every((t) => t.startsWith('#'))) {
|
|
46
50
|
found.push(`line 1 is "${rows[0] ?? ''}" — it must be exactly three tags`);
|
|
47
51
|
}
|
|
48
|
-
else
|
|
49
|
-
|
|
52
|
+
else {
|
|
53
|
+
if (!OUTCOMES.has(tags[2].slice(1))) {
|
|
54
|
+
found.push(`the outcome tag is "${tags[2]}" — the vocabulary is ok, fail, off, unknown, info`);
|
|
55
|
+
}
|
|
56
|
+
// The charset is checked HERE, not trusted to slug(): slug keeps any
|
|
57
|
+
// Unicode letter, so a Russian-named job produces a Cyrillic tag with no
|
|
58
|
+
// complaint anywhere — confirmed against render.ts on 31.08.2026.
|
|
59
|
+
for (const t of tags) {
|
|
60
|
+
if (!/^#[a-z0-9_]+$/.test(t)) {
|
|
61
|
+
found.push(`the tag "${t}" carries characters outside [a-z0-9_] — tags are English, lowercase`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// A dated tag groups nothing: every day mints a new one and the filter
|
|
65
|
+
// the tags exist for never collects two cards.
|
|
66
|
+
if (/(_|^#)20\d{2}_\d{2}_\d{2}$/.test(tags[1]) || /_20\d{6}$/.test(tags[1])) {
|
|
67
|
+
found.push(`the instance tag "${tags[1]}" ends in a date — a dated tag groups nothing`);
|
|
68
|
+
}
|
|
50
69
|
}
|
|
51
70
|
if (tags[1] === '#' || tags[1] === '#_') {
|
|
52
71
|
found.push('the instance tag is empty — it groups nothing and pairs with nothing');
|
|
@@ -98,17 +117,39 @@ export const lintCard = (html) => {
|
|
|
98
117
|
break;
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
// Rule L (v2.1): everything the SYSTEM says is English. Cyrillic is
|
|
121
|
+
// allowed only as QUOTED CONTENT — text that exists in Russian outside the
|
|
122
|
+
// card: a blockquote (commit bodies, issue bodies, an offender's lines),
|
|
123
|
+
// the text of a link (issue titles in digests), and the title slot on
|
|
124
|
+
// line 2. Everywhere else — a label, a bare field value, a tag, a command —
|
|
125
|
+
// it is system text and a fault.
|
|
126
|
+
const quotedStripped = html
|
|
127
|
+
.replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/g, '')
|
|
128
|
+
.replace(/<a href="[^"]*">[^<]*<\/a>/g, '')
|
|
129
|
+
// A commit's title rides the `Commit:` row after the hash — it is a
|
|
130
|
+
// commit message, the canonical quoted content.
|
|
131
|
+
.replace(/^<b>Commit:<\/b>.*$/gm, '')
|
|
132
|
+
// List items carry content (issue titles in a digest, findings) — the
|
|
133
|
+
// row's own text is the subject's, not the system's.
|
|
134
|
+
.replace(/^(?:•|\d+\.) .*$/gm, '');
|
|
135
|
+
const strippedRows = quotedStripped.split('\n');
|
|
136
|
+
// Line 2's value after the label is the one non-quoted slot allowed to
|
|
137
|
+
// carry a title written in Russian (issue/PR/report/incident titles).
|
|
138
|
+
if (strippedRows[1]) {
|
|
139
|
+
strippedRows[1] = strippedRows[1].replace(/(<b>[^<]+:<\/b>|<b>[^<]+<\/b>).*/, '$1');
|
|
140
|
+
}
|
|
141
|
+
if (/[а-яё]/i.test(strippedRows.join('\n'))) {
|
|
142
|
+
found.push('Cyrillic outside quoted content — system text is English (rule L)');
|
|
143
|
+
}
|
|
144
|
+
// Rule S (v2.1): a card that reports trouble must say where to verify it —
|
|
145
|
+
// a `Check:` command, a `Source:` link, a `To do:` command, or any link at
|
|
146
|
+
// all. A `Log:` path alone is not enough: a path cannot be tapped, and a
|
|
147
|
+
// card whose only pointer needs a file manager is a card with no pointer.
|
|
148
|
+
// `#fail` and `#unknown` both: a silent task has no log, but `config jobs
|
|
149
|
+
// --log <key>` answers it too.
|
|
150
|
+
const broke = (rows[0] ?? '').includes('#fail') || (rows[0] ?? '').includes('#unknown');
|
|
151
|
+
if (broke && !html.includes('<b>Check:</b>') && !html.includes('<b>To do:</b>') && !html.includes('<a href=')) {
|
|
152
|
+
found.push('a trouble card with no check command and no link — nowhere to look (rule S)');
|
|
112
153
|
}
|
|
113
154
|
return found;
|
|
114
155
|
};
|
package/dist/render.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare const esc: (v: unknown) => string;
|
|
|
36
36
|
* Telegram replies `400 can't parse entities`, and we treat a 4xx as a
|
|
37
37
|
* permanent error and do not retry — the message disappeared for good.
|
|
38
38
|
*/
|
|
39
|
-
export declare const clampMessage: (text: string, limit?: number) => string;
|
|
39
|
+
export declare const clampMessage: (text: string, limit?: number, marker?: string) => string;
|
|
40
40
|
export declare const slug: (raw: string) => string;
|
|
41
41
|
/**
|
|
42
42
|
* The instance tag: exactly which concrete event this is (branch,
|
|
@@ -81,10 +81,13 @@ export declare const OUTCOME_TAG: Readonly<Record<Icon, OutcomeTag>>;
|
|
|
81
81
|
export declare const outcomeTag: (e: NotifyEvent) => OutcomeTag;
|
|
82
82
|
/**
|
|
83
83
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
*
|
|
85
|
+
* Assembly is TAIL-FIRST (v2.1): the parts that must survive any cut — the
|
|
86
|
+
* tag line (the human filter and the parser's machine key), the pointer
|
|
87
|
+
* block (`Log`/`Check`/`Source`) and the cut marker — are measured before
|
|
88
|
+
* the body is clamped, and the body gets what is left. Under the old order
|
|
89
|
+
* the pointer was part of the body, so the longest cards lost exactly the
|
|
90
|
+
* line saying where to look.
|
|
88
91
|
*/
|
|
89
92
|
export declare const render: (e: NotifyEvent) => string;
|
|
90
93
|
export {};
|
package/dist/render.js
CHANGED
|
@@ -51,7 +51,7 @@ export const esc = (v) => String(v ?? '')
|
|
|
51
51
|
* Telegram replies `400 can't parse entities`, and we treat a 4xx as a
|
|
52
52
|
* permanent error and do not retry — the message disappeared for good.
|
|
53
53
|
*/
|
|
54
|
-
export const clampMessage = (text, limit = 4000) => {
|
|
54
|
+
export const clampMessage = (text, limit = 4000, marker = '…') => {
|
|
55
55
|
if (text.length <= limit) {
|
|
56
56
|
return text;
|
|
57
57
|
}
|
|
@@ -108,7 +108,12 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
108
108
|
.reverse()
|
|
109
109
|
.map((t) => `</${t}>`)
|
|
110
110
|
.join('');
|
|
111
|
-
|
|
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}`;
|
|
112
117
|
};
|
|
113
118
|
// Only the first line: a field is single-line by contract (commit, branch,
|
|
114
119
|
// author, a stat), not a place for a paragraph. A live case (18.08): a CI card
|
|
@@ -129,6 +134,21 @@ const firstLine = (value) => {
|
|
|
129
134
|
* value serializes as `null`, not as a missing key.
|
|
130
135
|
*/
|
|
131
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
|
+
};
|
|
132
152
|
/**
|
|
133
153
|
* An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
|
|
134
154
|
* one exists, otherwise the plain text of the same field — an identifier
|
|
@@ -483,15 +503,15 @@ const mechanism = (workflowName, via) => workflowName ?? via;
|
|
|
483
503
|
// unlinked, because a hand deploy has no run to open.
|
|
484
504
|
const renderDeploy = (e) => {
|
|
485
505
|
const icon = iconFor(e);
|
|
486
|
-
const runUrl = e.workflowUrl ?? e.url;
|
|
487
506
|
return join([
|
|
488
507
|
// The name of what shipped the deploy sits on the type line, the outcome
|
|
489
508
|
// in parens beside it — icon and tag alone were judged, live, not to be
|
|
490
509
|
// enough: a plain 🔴 next to a workflow name still read as "something
|
|
491
510
|
// happened," not "it failed," on a screen small enough to lose the color.
|
|
492
511
|
// The `Via` row is gone: it used to carry this same name one floor below.
|
|
493
|
-
|
|
494
|
-
|
|
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)], [
|
|
495
515
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
496
516
|
fieldPerson('Author', e.commitAuthor),
|
|
497
517
|
bodyQuote(e.commitBody)
|
|
@@ -517,27 +537,30 @@ const renderJob = (e) => {
|
|
|
517
537
|
// icon says and what the third tag now says too. The icon table on the
|
|
518
538
|
// catalogue page defines both marks.
|
|
519
539
|
return join([
|
|
520
|
-
|
|
521
|
-
|
|
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),
|
|
522
546
|
// The timetable is a different subject from this event: how often the task
|
|
523
547
|
// owes a sign of life and when it last gave one. It stood in a bare run
|
|
524
548
|
// under `Reason:` and read as more of the same. `Last run` when the task
|
|
525
549
|
// is alive, `Last seen` when it is not — one timestamp, two questions.
|
|
526
550
|
...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
|
|
527
551
|
...labelled(e.stats),
|
|
552
|
+
e.detail ? '' : null,
|
|
553
|
+
quoted(e.detailLabel ?? 'Detail', e.detail),
|
|
528
554
|
hasItems ? '' : null,
|
|
529
555
|
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
530
556
|
// list, so playhub's daily card of newly published games was headed
|
|
531
557
|
// "Disabled workflows".
|
|
532
558
|
disabledList ? group('Disabled workflows') : null,
|
|
533
559
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
534
|
-
e.command
|
|
535
|
-
fieldCode('Log', e.logs),
|
|
560
|
+
e.command ? '' : null,
|
|
536
561
|
...fieldRun(e.command, e.commandNote)
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// written twice, and it stood BELOW the `To do:` command, which is the
|
|
540
|
-
// 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.
|
|
541
564
|
]);
|
|
542
565
|
};
|
|
543
566
|
const renderReport = (e) => {
|
|
@@ -548,7 +571,7 @@ const renderReport = (e) => {
|
|
|
548
571
|
// without a word.
|
|
549
572
|
const numbers = labelled(e.lines);
|
|
550
573
|
return join([
|
|
551
|
-
typeLine(iconFor(e), 'Report', e.title,
|
|
574
|
+
typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
|
|
552
575
|
// Rows with no group of their own sit flush against the header instead of
|
|
553
576
|
// forming a separate slab under a blank line. `labelled` puts the blank
|
|
554
577
|
// line before the first group itself, so there is none here.
|
|
@@ -559,9 +582,8 @@ const renderReport = (e) => {
|
|
|
559
582
|
}
|
|
560
583
|
const items = bullets(e.items, false);
|
|
561
584
|
return join([
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
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),
|
|
565
587
|
// Flush against the header — see the branch above.
|
|
566
588
|
...labelled(e.lines),
|
|
567
589
|
items.length > 0 ? '' : null,
|
|
@@ -575,9 +597,9 @@ const renderReport = (e) => {
|
|
|
575
597
|
// spoke (`nightly`, `Quality`) — but both are the name on the type line.
|
|
576
598
|
const renderCi = (e) => {
|
|
577
599
|
const icon = iconFor(e);
|
|
578
|
-
const runUrl = e.workflowUrl ?? e.url;
|
|
579
600
|
return join([
|
|
580
|
-
|
|
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'),
|
|
581
603
|
// `Actor` used to be read as "who wrote the commit," and on most runs it
|
|
582
604
|
// is — `github.actor` for a push IS the person who pushed. It stops being
|
|
583
605
|
// that on a scheduled run: arvent's nightly rewrites it to whoever is on
|
|
@@ -585,7 +607,7 @@ const renderCi = (e) => {
|
|
|
585
607
|
// author. So Actor answers "who is responsible for this run," `Author`
|
|
586
608
|
// below the commit answers "who wrote this code" — two different people
|
|
587
609
|
// on a nightly card, the same person everywhere else.
|
|
588
|
-
...twoBlocks([
|
|
610
|
+
...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
589
611
|
fieldTelegram('Actor', e.actor),
|
|
590
612
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
591
613
|
fieldPerson('Author', e.commitAuthor),
|
|
@@ -622,21 +644,24 @@ const opening = (action, body) => action === 'opened' || VERDICT.has(action) ? b
|
|
|
622
644
|
// old order, title then Author then Assignee then finally the body — "why
|
|
623
645
|
// does the assignee cut apart what should be inseparable?"
|
|
624
646
|
const renderPr = (e) => join([
|
|
625
|
-
typeLine(iconFor(e), 'PR', named(e.number, e.title)
|
|
647
|
+
typeLine(iconFor(e), 'PR', named(e.number, e.title)),
|
|
626
648
|
opening(e.action, e.body),
|
|
627
649
|
(e.action === 'opened' || VERDICT.has(e.action)) && e.body ? '' : null,
|
|
628
650
|
fieldPerson('Author', e.author),
|
|
629
651
|
fieldPerson('Reviewer', e.reviewer)
|
|
630
652
|
]);
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
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.
|
|
636
661
|
const renderIssue = (e) => join([
|
|
637
|
-
typeLine(iconFor(e), 'Issue', named(e.number, e.title)
|
|
638
|
-
bodyQuote(e.body),
|
|
639
|
-
e.body ? '' : null,
|
|
662
|
+
typeLine(iconFor(e), 'Issue', named(e.number, e.title)),
|
|
663
|
+
e.action === 'opened' ? bodyQuote(e.body) : null,
|
|
664
|
+
e.action === 'opened' && e.body ? '' : null,
|
|
640
665
|
fieldPerson('Author', e.author),
|
|
641
666
|
fieldPerson('Assignee', e.assignee)
|
|
642
667
|
]);
|
|
@@ -651,12 +676,12 @@ const renderIssue = (e) => join([
|
|
|
651
676
|
const renderIncident = (e) => {
|
|
652
677
|
const findings = bullets(e.items, false);
|
|
653
678
|
return join([
|
|
654
|
-
typeLine(iconFor(e), 'Incident', e.title
|
|
679
|
+
typeLine(iconFor(e), 'Incident', e.title),
|
|
655
680
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
681
|
+
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
656
682
|
findings.length > 0 ? '' : null,
|
|
657
|
-
...findings
|
|
658
|
-
|
|
659
|
-
fieldCode('Log', e.logs)
|
|
683
|
+
...findings
|
|
684
|
+
// `Log:` moved to the pointer block `render` appends — see renderJob.
|
|
660
685
|
]);
|
|
661
686
|
};
|
|
662
687
|
// A session in trouble. Same law as every other card: identifier first, then
|
|
@@ -673,7 +698,8 @@ const renderSession = (e) => join([
|
|
|
673
698
|
// opened a block that had no heading. Facts about the session touch the
|
|
674
699
|
// line that names it, the way they do on every job card.
|
|
675
700
|
field('Project', e.workdir),
|
|
676
|
-
|
|
701
|
+
reason('Reason', e.reason),
|
|
702
|
+
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
677
703
|
e.opened ? '' : null,
|
|
678
704
|
quoted('Opened with', e.opened),
|
|
679
705
|
e.command ? '' : null,
|
|
@@ -798,12 +824,62 @@ export const OUTCOME_TAG = {
|
|
|
798
824
|
};
|
|
799
825
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
800
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
|
+
};
|
|
801
874
|
/**
|
|
802
875
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
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.
|
|
807
883
|
*/
|
|
808
884
|
export const render = (e) => {
|
|
809
885
|
const renderer = RENDERERS[e.type];
|
|
@@ -814,11 +890,14 @@ export const render = (e) => {
|
|
|
814
890
|
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
815
891
|
}
|
|
816
892
|
const tags = tagsLine(e);
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
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}`;
|
|
824
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>;
|
package/dist/send.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* allowed to bring down the deploy or the scheduled task that called it.
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { eventKey, outcomeTag, render } from "./render.js";
|
|
21
22
|
import { lintCard } from "./lint.js";
|
|
22
23
|
import { ROUTES, targets } from "./routes.js";
|
|
23
24
|
const log = (msg) => {
|
|
@@ -257,6 +258,99 @@ const sendFile = async (e) => {
|
|
|
257
258
|
}
|
|
258
259
|
return results.includes('sent') ? 'sent' : 'failed';
|
|
259
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* Repeat suppression (v2.1). A failure that is still the same failure does
|
|
263
|
+
* not resend every run: the first card goes out, repeats inside the window
|
|
264
|
+
* are swallowed, and past the window ONE card a day goes out carrying
|
|
265
|
+
* `Still red: day N`. A green outcome clears the record — without that, a
|
|
266
|
+
* new failure would inherit the old one's day counter and the recovery
|
|
267
|
+
* itself would never be told.
|
|
268
|
+
*
|
|
269
|
+
* The key is project + type + instance — DELIBERATELY no free text: both
|
|
270
|
+
* external reviews independently showed that normalizing prose (stripping
|
|
271
|
+
* digits, hexes, paths) merges different failures — "connect to host A" and
|
|
272
|
+
* "connect to host B" become one key and the second failure goes silent. A
|
|
273
|
+
* job that covers several targets owes each target its own `--key`.
|
|
274
|
+
*
|
|
275
|
+
* Every failure of the mechanism itself fails OPEN: a broken state file, a
|
|
276
|
+
* held lock, an unwritable directory all mean "send". A duplicate card is a
|
|
277
|
+
* small cost; a swallowed alarm is not.
|
|
278
|
+
*/
|
|
279
|
+
const WINDOW_MS = 20 * 3600_000;
|
|
280
|
+
const DAY_MS = 24 * 3600_000;
|
|
281
|
+
const statePath = () => process.env.NOTIFY_STATE?.trim() || join(homedir(), '.claude', '.runs', 'notify-sent.json');
|
|
282
|
+
/** Exported for tests only — the time is injectable so day counting is provable. */
|
|
283
|
+
export const dedupe = (e, now = Date.now()) => {
|
|
284
|
+
const file = statePath();
|
|
285
|
+
const lock = `${file}.lock`;
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
288
|
+
// The lock is a directory: mkdir is atomic on every filesystem this runs
|
|
289
|
+
// on. A held lock means another sender is mid-write — fail open.
|
|
290
|
+
mkdirSync(lock);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return { action: 'send' };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
let state = {};
|
|
297
|
+
try {
|
|
298
|
+
state = JSON.parse(readFileSync(file, 'utf-8'));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
state = {}; // missing or broken JSON — start clean, never swallow
|
|
302
|
+
}
|
|
303
|
+
const key = `${String(e.project)}:${String(e.type)}:${eventKey(e)}`;
|
|
304
|
+
const rec = state[key];
|
|
305
|
+
const write = () => {
|
|
306
|
+
const tmp = `${file}.tmp`;
|
|
307
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
308
|
+
renameSync(tmp, file);
|
|
309
|
+
};
|
|
310
|
+
if (outcomeTag(e) !== 'fail') {
|
|
311
|
+
if (rec) {
|
|
312
|
+
delete state[key];
|
|
313
|
+
write();
|
|
314
|
+
}
|
|
315
|
+
return { action: 'send' };
|
|
316
|
+
}
|
|
317
|
+
if (!rec) {
|
|
318
|
+
state[key] = { first: new Date(now).toISOString(), last: new Date(now).toISOString(), count: 0 };
|
|
319
|
+
write();
|
|
320
|
+
return { action: 'send' };
|
|
321
|
+
}
|
|
322
|
+
const last = Date.parse(rec.last);
|
|
323
|
+
const first = Date.parse(rec.first);
|
|
324
|
+
if (Number.isNaN(last) || Number.isNaN(first)) {
|
|
325
|
+
delete state[key];
|
|
326
|
+
write();
|
|
327
|
+
return { action: 'send' };
|
|
328
|
+
}
|
|
329
|
+
if (now - last < WINDOW_MS) {
|
|
330
|
+
rec.count += 1;
|
|
331
|
+
write();
|
|
332
|
+
log(`suppressed: same failure "${key}" already reported ${rec.count} time(s) in the window`);
|
|
333
|
+
return { action: 'suppress' };
|
|
334
|
+
}
|
|
335
|
+
rec.last = new Date(now).toISOString();
|
|
336
|
+
write();
|
|
337
|
+
// Day 1 is the day the first card went out; the counter only appears
|
|
338
|
+
// from day 2 on — "Still red: day 1" would restate the card itself.
|
|
339
|
+
const day = Math.floor((now - first) / DAY_MS) + 1;
|
|
340
|
+
return { action: 'send', ...(day >= 2 ? { stillRed: day } : {}) };
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return { action: 'send' };
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
try {
|
|
347
|
+
rmdirSync(lock);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// the lock directory is gone or never ours — nothing to release
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
260
354
|
/**
|
|
261
355
|
* Sends the event to all of its targets (the project topic, plus
|
|
262
356
|
* `incidents` if needed, plus the team chat). Targets are handled one after
|
|
@@ -281,6 +375,7 @@ const reportLostProject = async (project, kind) => {
|
|
|
281
375
|
job: 'notify: an event was lost',
|
|
282
376
|
status: 'fail',
|
|
283
377
|
note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
|
|
378
|
+
check: 'npx --yes @mikitasazan/notify routes --json',
|
|
284
379
|
key: 'notify-unknown-project'
|
|
285
380
|
};
|
|
286
381
|
await deliver(targets(lost), render(lost)).catch(() => undefined);
|
|
@@ -290,21 +385,76 @@ const reportLostProject = async (project, kind) => {
|
|
|
290
385
|
* worth losing over its own formatting — and the breach is raised as its own
|
|
291
386
|
* red card, the way a lost project is.
|
|
292
387
|
*
|
|
388
|
+
* The watchdog card names the offender: the first two content lines of the
|
|
389
|
+
* card it complains about, quoted verbatim under `Offender:` so the owner
|
|
390
|
+
* can find it in the feed. Its `Check:` is a real command (the mockups'
|
|
391
|
+
* `lint-text < card.txt` pointed at a file the owner does not have — a fake
|
|
392
|
+
* pointer is worse than none), and the offender's full text goes to the
|
|
393
|
+
* failure log the session start already reads.
|
|
394
|
+
*
|
|
293
395
|
* `key` carries the type, so a renderer that starts producing broken deploy
|
|
294
|
-
* cards raises one running complaint rather than a new one every hour
|
|
295
|
-
*
|
|
396
|
+
* cards raises one running complaint rather than a new one every hour — and
|
|
397
|
+
* the card passes through the same `dedupe` as any failure, so it cannot
|
|
398
|
+
* loop daily on one unfixed offender.
|
|
399
|
+
*
|
|
400
|
+
* The watchdog lints ITSELF (v2.1 — the v1 card failed its own lint and
|
|
401
|
+
* nothing noticed). If its own card is at fault it still goes out, and the
|
|
402
|
+
* breach lands in the failure log: silence is the one thing it may not do.
|
|
296
403
|
*/
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
404
|
+
const failuresLog = () => process.env.NOTIFY_FAILLOG?.trim() || join(homedir(), '.claude', '.runs', 'notify-fail.failures.log');
|
|
405
|
+
const journal = (line) => {
|
|
406
|
+
try {
|
|
407
|
+
const file = failuresLog();
|
|
408
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
409
|
+
appendFileSync(file, `${new Date().toISOString()} ${line}\n`);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// the journal is best-effort: a card must never be lost to bookkeeping
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
/** Exported for tests: the watchdog's own card must provably pass the lint. */
|
|
416
|
+
export const brokenCardEvent = (e, faults, offenderHtml) => {
|
|
417
|
+
const offender = offenderHtml
|
|
418
|
+
.split('\n')
|
|
419
|
+
.slice(1) // the tag line names nothing a human reads
|
|
420
|
+
.map((r) => r.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim())
|
|
421
|
+
.filter((r) => r !== '')
|
|
422
|
+
.slice(0, 2)
|
|
423
|
+
.join('\n');
|
|
424
|
+
return {
|
|
300
425
|
type: 'job',
|
|
301
426
|
project: 'mac-config',
|
|
302
427
|
job: 'notify: a card broke the standard',
|
|
303
428
|
status: 'fail',
|
|
304
|
-
|
|
429
|
+
// The faults go in the LIST, not in Reason: a fault message quotes the
|
|
430
|
+
// offending text verbatim ("the tag #аналитика carries…"), and quoted
|
|
431
|
+
// text inside a system field is exactly what rule L forbids — the
|
|
432
|
+
// watchdog was failing its own lint on any Russian-speaking offender
|
|
433
|
+
// (caught live by its own journal, 31.08.2026).
|
|
434
|
+
note: `a ${String(e.type)} card for ${String(e.project)} broke ${faults.length} rule(s)`,
|
|
435
|
+
items: faults.map((f) => ({ text: f, group: 'Faults' })),
|
|
436
|
+
detail: offender || undefined,
|
|
437
|
+
detailLabel: 'Offender',
|
|
438
|
+
check: 'config jobs --log notify-broken',
|
|
305
439
|
key: `notify-broken-${String(e.type)}`
|
|
306
440
|
};
|
|
307
|
-
|
|
441
|
+
};
|
|
442
|
+
const reportBrokenCard = async (e, faults, offenderHtml) => {
|
|
443
|
+
log(`card does not match the standard: ${faults.join('; ')}`);
|
|
444
|
+
const broken = brokenCardEvent(e, faults, offenderHtml);
|
|
445
|
+
const dup = dedupe(broken);
|
|
446
|
+
if (dup.action === 'suppress') {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (dup.stillRed) {
|
|
450
|
+
broken.stillRed = dup.stillRed;
|
|
451
|
+
}
|
|
452
|
+
const html = render(broken);
|
|
453
|
+
const ownFaults = lintCard(html);
|
|
454
|
+
if (ownFaults.length > 0) {
|
|
455
|
+
journal(`notify-watchdog: own card failed lint: ${ownFaults.join('; ')}`);
|
|
456
|
+
}
|
|
457
|
+
await deliver(targets(broken), html).catch(() => undefined);
|
|
308
458
|
};
|
|
309
459
|
export const notify = async (e) => {
|
|
310
460
|
// Object.hasOwn, not `in`: `in` walks the prototype chain, and
|
|
@@ -313,6 +463,17 @@ export const notify = async (e) => {
|
|
|
313
463
|
await reportLostProject(e.project, String(e.type));
|
|
314
464
|
return 'skipped';
|
|
315
465
|
}
|
|
466
|
+
// The same unresolved failure does not resend: the window swallows it, one
|
|
467
|
+
// card a day carries `Still red: day N`. Suppression reports 'sent' to the
|
|
468
|
+
// caller — the words sent/failed/skipped are a watchdog contract about the
|
|
469
|
+
// delivery PIPELINE, and a deliberate swallow is the pipeline working.
|
|
470
|
+
const dup = dedupe(e);
|
|
471
|
+
if (dup.action === 'suppress') {
|
|
472
|
+
return 'sent';
|
|
473
|
+
}
|
|
474
|
+
if (dup.stillRed) {
|
|
475
|
+
e.stillRed = dup.stillRed;
|
|
476
|
+
}
|
|
316
477
|
// A card with a file becomes the caption of that file — one card, not two.
|
|
317
478
|
if (e.path) {
|
|
318
479
|
return sendFile(e);
|
|
@@ -323,7 +484,7 @@ export const notify = async (e) => {
|
|
|
323
484
|
const result = await deliver(targets(e), html);
|
|
324
485
|
const faults = lintCard(html);
|
|
325
486
|
if (faults.length > 0) {
|
|
326
|
-
await reportBrokenCard(e, faults);
|
|
487
|
+
await reportBrokenCard(e, faults, html);
|
|
327
488
|
}
|
|
328
489
|
return result;
|
|
329
490
|
};
|
package/package.json
CHANGED