@mikitasazan/notify 1.15.1 → 1.16.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 +15 -3
- package/dist/cli-flags.js +2 -2
- package/dist/cli.js +25 -19
- package/dist/events.d.ts +67 -16
- package/dist/events.js +2 -1
- package/dist/lint.js +4 -1
- package/dist/render.d.ts +11 -0
- package/dist/render.js +233 -91
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @mikitasazan/notify
|
|
2
2
|
|
|
3
3
|
Единая типизированная отправка Telegram-уведомлений: **форум на проект**,
|
|
4
|
-
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот,
|
|
4
|
+
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот, семь типов
|
|
5
5
|
событий. Заменяет 18 разных имён переменных и 4+ независимых реализации
|
|
6
6
|
`jq | curl`, что были в playhub, arvent, game-publisher до 26.07.2026.
|
|
7
7
|
|
|
@@ -84,10 +84,22 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
84
84
|
| `ci` | итог CI на master | `project`, `status` |
|
|
85
85
|
| `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
|
|
86
86
|
| `issue` | событие задачи | `project`, `action`, `number`, `title` |
|
|
87
|
-
| `incident` |
|
|
88
|
-
| `session` |
|
|
87
|
+
| `incident` | что-то встало и ждёт тебя прямо сейчас: приложение, сейф, рабочая сессия на маке | `project`, `title` |
|
|
88
|
+
| `session` | УСТАРЕЛ, это `incident`: карточка выходит с тегом `#incident`, имя собирается из `action` — `Claude session is burning the limit` | `project`, `action` |
|
|
89
89
|
| `heartbeat_miss` | УСТАРЕЛ, молчание — это `job --status silent` | `project`, `job` |
|
|
90
90
|
|
|
91
|
+
Скобка у слова типа говорит одним словом, чем кончилось: `Deploy (OK)`,
|
|
92
|
+
`CI (Fail)`, `Job (Off)`, `Job (Silent)`, `Issue (Assigned)`, `PR (Merged)`.
|
|
93
|
+
Она стоит там, где у типа исходов больше одного. У `incident` состояние одно,
|
|
94
|
+
поэтому его скобка называет МЕСТО, где горит: `Incident (Vault)`,
|
|
95
|
+
`Incident (Session)` — слово из `--scope`, без него имя проекта. У `report`
|
|
96
|
+
исхода нет вовсе, и скобку занимает день: `Report (2026-08-23 / 2026-08-22)`.
|
|
97
|
+
|
|
98
|
+
У `job` есть ещё два необязательных флага: `--via` — где задача крутилась
|
|
99
|
+
(`mac`, `vps`, `actions`), это идёт строкой `Via: Mac` первой под второй
|
|
100
|
+
строкой; и `--took` — сколько прогон занял, твоими же словами (`4m 12s`),
|
|
101
|
+
строкой `Took:` под причиной. Не передал — строки нет.
|
|
102
|
+
|
|
91
103
|
Отдельного вида «файл» нет: `--path` применим к ЛЮБОМУ событию, и тогда
|
|
92
104
|
карточка едет подписью к вложению (подпись у Telegram ограничена 1024 знаками,
|
|
93
105
|
а не 4000). Слово `file` осталось псевдонимом и собирает `report` с вложением —
|
package/dist/cli-flags.js
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
|
-
'id', 'opened', 'reason', 'workdir',
|
|
13
|
+
'id', 'opened', 'reason', 'scope', 'workdir',
|
|
14
14
|
'check', 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url',
|
|
15
15
|
'detail', 'detail-label',
|
|
16
16
|
'expected', 'filename', 'item',
|
|
17
17
|
'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
18
18
|
'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
19
|
-
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
19
|
+
'target', 'title', 'took', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
20
20
|
// Flags with no value. They live here too, so parsing and the list do not drift apart.
|
|
21
21
|
'json', 'recovered', 'dry-run'
|
|
22
22
|
]);
|
package/dist/cli.js
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* is no scenario where a deploy should fail because a message did not send.
|
|
12
12
|
*
|
|
13
13
|
* notify deploy --project playhub --status ok --commit "msg" [--commit-url "..."] --url "..."
|
|
14
|
-
* notify job --project playhub --job "Game import" --status ok --stat "added=5"
|
|
14
|
+
* notify job --project playhub --job "Game import" --status ok --stat "added=5" [--via vps] [--took "4m 12s"]
|
|
15
15
|
* notify report --project playhub --title "Daily summary" --line "Games=1284"
|
|
16
|
-
* notify ci --project arvent --status fail --branch master
|
|
16
|
+
* notify ci --project arvent --status fail --branch master
|
|
17
17
|
* notify pr --project arvent --action opened --number 142 --title "..."
|
|
18
18
|
* notify incident --project arvent --title "Redis is unreachable" --detail "$ERR"
|
|
19
|
+
* notify session --project mac-config --action "burning the limit" # DEPRECATED alias of incident
|
|
19
20
|
* notify file --project arvent --title "Full dialogues" --path ./out.txt [--filename name.txt]
|
|
20
21
|
* notify <type> [--key stable-key] # the task's key on the card's last line
|
|
21
22
|
* notify <type> --json < payload.json # the whole event object on stdin
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
*/
|
|
24
25
|
import { readFileSync } from 'node:fs';
|
|
25
26
|
import { KNOWN_FLAGS } from "./cli-flags.js";
|
|
26
|
-
import { render } from "./render.js";
|
|
27
|
+
import { render, sessionTitle } from "./render.js";
|
|
27
28
|
import { lintCard } from "./lint.js";
|
|
28
29
|
import { notify } from "./send.js";
|
|
29
30
|
import { ROUTES } from "./routes.js";
|
|
@@ -272,6 +273,11 @@ else {
|
|
|
272
273
|
job: one('job') ?? '(no name)',
|
|
273
274
|
status: jobStatus(),
|
|
274
275
|
aside: one('aside'),
|
|
276
|
+
// Where it ran and how long it took: `--via mac`, `--took '4m 12s'`.
|
|
277
|
+
// `--took` fills `duration` — the flag is the sender's word for the
|
|
278
|
+
// question ("how long did it take?"), the field is the card's.
|
|
279
|
+
via: one('via'),
|
|
280
|
+
duration: one('took'),
|
|
275
281
|
expected: one('expected'),
|
|
276
282
|
lastSeen: one('last-seen'),
|
|
277
283
|
stats: pairs('stat'),
|
|
@@ -344,30 +350,30 @@ else {
|
|
|
344
350
|
url: one('url')
|
|
345
351
|
};
|
|
346
352
|
break;
|
|
353
|
+
// DEPRECATED since 1.16.0. `notify session` is an alias of `notify
|
|
354
|
+
// incident`: the two cards said one thing — something is stuck and waits
|
|
355
|
+
// for you — under two names and the same 🚨. The subcommand still works
|
|
356
|
+
// because the runaway guard on this Mac calls it, and it builds an
|
|
357
|
+
// incident whose title names the session: `Claude session is burning the
|
|
358
|
+
// limit`. `--status` is read no more; an incident has one state.
|
|
347
359
|
case 'session':
|
|
348
|
-
event = {
|
|
349
|
-
type: 'session',
|
|
350
|
-
project: project(),
|
|
351
|
-
action: one('action') ?? 'in trouble',
|
|
352
|
-
id: one('id'),
|
|
353
|
-
workdir: one('workdir'),
|
|
354
|
-
reason: one('reason'),
|
|
355
|
-
opened: one('opened'),
|
|
356
|
-
command: one('command'),
|
|
357
|
-
commandNote: one('command-note'),
|
|
358
|
-
// Only two states here, so `disabled` must not leak in from jobStatus.
|
|
359
|
-
status: jobStatus() === 'ok' ? 'ok' : 'fail'
|
|
360
|
-
};
|
|
361
|
-
break;
|
|
362
360
|
case 'incident':
|
|
363
361
|
event = {
|
|
364
362
|
type: 'incident',
|
|
365
363
|
project: project(),
|
|
366
|
-
title:
|
|
364
|
+
title: command === 'session'
|
|
365
|
+
? sessionTitle(one('action') ?? 'in trouble')
|
|
366
|
+
: (one('title') ?? '(no title)'),
|
|
367
|
+
scope: command === 'session' ? 'Session' : one('scope'),
|
|
367
368
|
detail: one('detail'),
|
|
368
369
|
items: items(),
|
|
369
370
|
logs: one('logs'),
|
|
370
|
-
url: one('url')
|
|
371
|
+
url: one('url'),
|
|
372
|
+
workdir: one('workdir'),
|
|
373
|
+
reason: one('reason'),
|
|
374
|
+
opened: one('opened'),
|
|
375
|
+
command: one('command'),
|
|
376
|
+
commandNote: one('command-note')
|
|
371
377
|
};
|
|
372
378
|
break;
|
|
373
379
|
case 'heartbeat_miss':
|
package/dist/events.d.ts
CHANGED
|
@@ -141,13 +141,32 @@ export type NotifyEvent = Keyed & (
|
|
|
141
141
|
*/
|
|
142
142
|
status: 'ok' | 'fail' | 'disabled' | 'silent';
|
|
143
143
|
/**
|
|
144
|
-
* The one
|
|
145
|
-
*
|
|
146
|
-
* the
|
|
147
|
-
*
|
|
148
|
-
*
|
|
144
|
+
* The one sentence that says why this card exists at all («reporting
|
|
145
|
+
* again»). It has no bracket of its own any more — the bracket on a job
|
|
146
|
+
* carries the outcome — and prints in the `Reason:` row, but only when
|
|
147
|
+
* the sender left `note` empty; a real reason is never duplicated.
|
|
148
|
+
* The field stays for the senders that still pass it.
|
|
149
149
|
*/
|
|
150
150
|
aside?: string;
|
|
151
|
+
/**
|
|
152
|
+
* WHERE the job ran, one word from the sender: `mac` (launchd on this
|
|
153
|
+
* Mac), `vps` (a cron on the server), `actions` (GitHub Actions). Any
|
|
154
|
+
* other word is passed through and capitalised. It prints as a `Via:`
|
|
155
|
+
* row directly under the type line — it is a fact ABOUT the run, and
|
|
156
|
+
* the bracket on the type word belongs to the outcome. Until now the
|
|
157
|
+
* Mac's launchd, a
|
|
158
|
+
* VPS cron, GitHub Actions and the silence watchdog all arrived under
|
|
159
|
+
* one bare `#job` tag and only memory told them apart; deploy has said
|
|
160
|
+
* «via GitHub Actions» from the start, and job now does too
|
|
161
|
+
* (03.09.2026).
|
|
162
|
+
*/
|
|
163
|
+
via?: string;
|
|
164
|
+
/**
|
|
165
|
+
* How long the run took, in the sender's own words: `4m 12s`, `38s`.
|
|
166
|
+
* Printed as a `Took:` row right under the reason. Absent — no row:
|
|
167
|
+
* a job that does not measure itself must not be made to say `0s`.
|
|
168
|
+
*/
|
|
169
|
+
duration?: string;
|
|
151
170
|
/** How often the task owes a check-in — for `silent` and for recovering from it. */
|
|
152
171
|
expected?: string;
|
|
153
172
|
/** When it was last seen. */
|
|
@@ -327,22 +346,50 @@ export type NotifyEvent = Keyed & (
|
|
|
327
346
|
/** A local path to the logs (not a URL — renders monospaced, to copy, not to click). */
|
|
328
347
|
logs?: string;
|
|
329
348
|
url?: string;
|
|
349
|
+
/**
|
|
350
|
+
* The rest of this block arrived with the `session` type, which was
|
|
351
|
+
* folded into `incident` on 03.09.2026. Two types said one thing —
|
|
352
|
+
* something is stuck and waits for you — under the same 🚨, and the
|
|
353
|
+
* owner cut it to one. The fields are not session-only in meaning: a
|
|
354
|
+
* stuck deploy has a working directory too, and any alarm can carry a
|
|
355
|
+
* command that gets you out of it.
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* WHERE it burns, one word, printed in the bracket on the type word:
|
|
359
|
+
* `Incident (Vault):`, `Incident (Session):`. The owner (03.09.2026):
|
|
360
|
+
* the bracket should throw the place at the eye before anything is
|
|
361
|
+
* read. Not an outcome — an incident has one — so this is the one
|
|
362
|
+
* bracket that names a place. Absent → the project name, capitalised.
|
|
363
|
+
*/
|
|
364
|
+
scope?: string;
|
|
365
|
+
/** Which working copy this is about, when several of them look alike. */
|
|
366
|
+
workdir?: string;
|
|
367
|
+
/** One line of measurement: what the guard saw. */
|
|
368
|
+
reason?: string;
|
|
369
|
+
/**
|
|
370
|
+
* A long quotation of someone's own writing — the line he opened a
|
|
371
|
+
* session with. Quoted, never a field: a field would clip it to one
|
|
372
|
+
* short line, which is exactly how the first version of that card lost
|
|
373
|
+
* it.
|
|
374
|
+
*/
|
|
375
|
+
opened?: string;
|
|
376
|
+
/** A command for him to run, monospaced so Telegram makes it copyable. */
|
|
377
|
+
command?: string;
|
|
378
|
+
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
379
|
+
commandNote?: string;
|
|
330
380
|
}
|
|
331
381
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* session
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* identity nothing else here has — an id, a working directory, and the line
|
|
339
|
-
* he typed to start it, which is the ONLY thing that tells two of his open
|
|
340
|
-
* sessions apart.
|
|
382
|
+
* DEPRECATED since 1.16.0: send an `incident`. The type is still ACCEPTED
|
|
383
|
+
* and renders as one — the runaway guard on this Mac
|
|
384
|
+
* (`context-runaway-notify.sh`) sends exactly this and must keep working —
|
|
385
|
+
* but `#session` is never printed again: the card comes out `#incident`.
|
|
386
|
+
* It was a separate type because a session has an id, a working directory
|
|
387
|
+
* and the line he typed to open it; `incident` carries all three now.
|
|
341
388
|
*/
|
|
342
389
|
| {
|
|
343
390
|
type: 'session';
|
|
344
391
|
project: Project;
|
|
345
|
-
/** What happened
|
|
392
|
+
/** What happened. Line 2 builds the incident's name out of it: `burning the limit` → `Claude session is burning the limit`. */
|
|
346
393
|
action: string;
|
|
347
394
|
/** The session's own id. Never printed as a field — he cannot type it or search it; it only reaches the card inside the `command`'s `rm`. */
|
|
348
395
|
id?: string;
|
|
@@ -360,7 +407,11 @@ export type NotifyEvent = Keyed & (
|
|
|
360
407
|
command?: string;
|
|
361
408
|
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
362
409
|
commandNote?: string;
|
|
363
|
-
/**
|
|
410
|
+
/**
|
|
411
|
+
* Kept for old senders and no longer read: an incident is an alarm by
|
|
412
|
+
* definition, and no sender ever passed `ok` here — the runaway guard
|
|
413
|
+
* only ever sends the bad news.
|
|
414
|
+
*/
|
|
364
415
|
status?: 'fail' | 'ok';
|
|
365
416
|
}
|
|
366
417
|
/**
|
package/dist/events.js
CHANGED
|
@@ -73,8 +73,9 @@ export const iconFor = (e) => {
|
|
|
73
73
|
// `undefined` at the head of line 2. Not knowing is itself a state the
|
|
74
74
|
// package already has a word and a sound for.
|
|
75
75
|
return JOB_ICON[e.status] ?? ICON.unknown;
|
|
76
|
+
// A session IS an incident since 03.09.2026 — same icon, same sound, same
|
|
77
|
+
// tag. It is listed separately only because the type name still exists.
|
|
76
78
|
case 'session':
|
|
77
|
-
return e.status === 'ok' ? ICON.ok : ICON.alarm;
|
|
78
79
|
case 'incident':
|
|
79
80
|
return ICON.alarm;
|
|
80
81
|
case 'heartbeat_miss':
|
package/dist/lint.js
CHANGED
|
@@ -36,8 +36,11 @@ const OUTCOMES = new Set(['ok', 'fail', 'off', 'unknown', 'info']);
|
|
|
36
36
|
* Labels the template retired. Each one used to say what a neighbour said.
|
|
37
37
|
* `Check` LEFT this list in v2.1: rule S brought it back as the standard
|
|
38
38
|
* verification-command row. `Logs` stays retired — the new spelling is `Log`.
|
|
39
|
+
* `Via` left it on 03.09.2026: the bracket on the type word now says how the
|
|
40
|
+
* job ENDED, so where it ran has nowhere else to go and is a row again — and
|
|
41
|
+
* it never duplicated a neighbour, which is what this list is for.
|
|
39
42
|
*/
|
|
40
|
-
const RETIRED = ['Title', 'Number', 'State', '
|
|
43
|
+
const RETIRED = ['Title', 'Number', 'State', 'Logs', 'Task', 'Id', 'Period'];
|
|
41
44
|
/**
|
|
42
45
|
* Reads a finished card and returns what is wrong with it, in the owner's
|
|
43
46
|
* terms. An empty array means the card obeys the standard.
|
package/dist/render.d.ts
CHANGED
|
@@ -59,6 +59,17 @@ export declare const clampMessage: (text: string, limit?: number, marker?: strin
|
|
|
59
59
|
* the patterns contain no `<>&"`, so nothing the author typed can become a tag.
|
|
60
60
|
*/
|
|
61
61
|
export declare const markdownToTelegram: (text: string) => string;
|
|
62
|
+
/**
|
|
63
|
+
* A session has no name of its own, so the incident's title is built out of
|
|
64
|
+
* what happened to it: `burning the limit` → `Claude session is burning the
|
|
65
|
+
* limit`. That is the whole of what the separate `session` card ever said
|
|
66
|
+
* differently — every other row it had is an incident row now.
|
|
67
|
+
*
|
|
68
|
+
* The 36-character id is still not printed: he cannot type it, cannot search
|
|
69
|
+
* it and cannot act on it. It reaches the card only inside the `rm` command at
|
|
70
|
+
* the bottom, which is the one place it is of any use.
|
|
71
|
+
*/
|
|
72
|
+
export declare const sessionTitle: (action: string | undefined) => string;
|
|
62
73
|
export declare const slug: (raw: string) => string;
|
|
63
74
|
/**
|
|
64
75
|
* The instance tag: exactly which concrete event this is (branch,
|
package/dist/render.js
CHANGED
|
@@ -127,6 +127,15 @@ const firstLine = (value) => {
|
|
|
127
127
|
}
|
|
128
128
|
return `${value.split('\n')[0]}…`;
|
|
129
129
|
};
|
|
130
|
+
/**
|
|
131
|
+
* The first value the sender actually filled in, trimmed; `undefined` when
|
|
132
|
+
* every one of them came in empty. Neither `??` nor `||` alone can do this:
|
|
133
|
+
* `??` keeps an empty string and so takes the fallback away, `||` still lets
|
|
134
|
+
* a whitespace-only one through to a visibly empty row or bracket. Both `''`
|
|
135
|
+
* and `' '` reach us for real — from a `--json` payload and from a shell
|
|
136
|
+
* flag whose variable expanded to nothing.
|
|
137
|
+
*/
|
|
138
|
+
const filled = (...values) => values.map((v) => String(v ?? '').trim()).find((v) => v !== '');
|
|
130
139
|
/**
|
|
131
140
|
* A field: `<b>Label:</b> value` — a bold, capitalized label, a plain value.
|
|
132
141
|
* `null` is dropped the same as `undefined`/`''` — the field's sources are
|
|
@@ -168,13 +177,14 @@ const fieldLink = (label, url, text) => {
|
|
|
168
177
|
/** A monospace field — a path/command to copy, not a link. */
|
|
169
178
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
170
179
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
180
|
+
* People rows are printed ALWAYS, the owner included. They used to be
|
|
181
|
+
* suppressed when the login/handle was his own (`mikitasazan`/`chelsnebes`),
|
|
182
|
+
* on the theory that a row naming the reader is not news. In practice a card
|
|
183
|
+
* whose people rows vanish only for him reads as a card with a hole: he saw
|
|
184
|
+
* Issue #322 and PR #118 with no `Author:` at all and asked where the people
|
|
185
|
+
* had gone. Every card now carries the same set of rows, so an absent row
|
|
186
|
+
* means an empty field and nothing else (03.09.2026).
|
|
176
187
|
*/
|
|
177
|
-
const OWNER = { github: 'mikitasazan', telegram: 'chelsnebes' };
|
|
178
188
|
/**
|
|
179
189
|
* A person field — Author, Assignee, Reviewer. Every one of them is a
|
|
180
190
|
* GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
|
|
@@ -192,31 +202,8 @@ const fieldPerson = (label, login) => {
|
|
|
192
202
|
return null;
|
|
193
203
|
}
|
|
194
204
|
const oneLine = firstLine(login);
|
|
195
|
-
if (oneLine.toLowerCase() === OWNER.github) {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
205
|
return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
|
|
199
206
|
};
|
|
200
|
-
/**
|
|
201
|
-
* `Actor` on a CI card is a Telegram handle, not a GitHub login
|
|
202
|
-
* (`nightly.yml`, step "Кто чинит" — "по «@chelsnebes» приходит уведомление
|
|
203
|
-
* тому, кто чинит, по «mikitasazan» — нет"), so it links to Telegram, not
|
|
204
|
-
* GitHub: `github.com/@chelsnebes` would open a page that does not exist.
|
|
205
|
-
* Telegram DOES auto-link a bare `@handle` on its own, but the owner asked
|
|
206
|
-
* for an explicit link like every other identifier on the card, not an
|
|
207
|
-
* implicit one riding on a client behavior he cannot see from here.
|
|
208
|
-
*/
|
|
209
|
-
const fieldTelegram = (label, handle) => {
|
|
210
|
-
if (!handle) {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
const oneLine = firstLine(handle);
|
|
214
|
-
const bare = oneLine.replace(/^@/, '');
|
|
215
|
-
if (bare.toLowerCase() === OWNER.telegram) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(bare)}">${esc(oneLine)}</a>`;
|
|
219
|
-
};
|
|
220
207
|
/**
|
|
221
208
|
* A row that asks something FROM THE OWNER, rather than reports a fact. It
|
|
222
209
|
* already stood last, set off by a blank line, and still read as an
|
|
@@ -350,12 +337,28 @@ export const markdownToTelegram = (text) => {
|
|
|
350
337
|
.filter((c) => c !== '')
|
|
351
338
|
.join(' · ');
|
|
352
339
|
}
|
|
340
|
+
// Each replace used to run over the OUTPUT of the previous one, so a tag
|
|
341
|
+
// this function had just written was still visible to the next pattern.
|
|
342
|
+
// Two shapes came out illegal for Telegram, and Telegram answers 4xx to
|
|
343
|
+
// both — the package does not retry a 4xx, so the whole card is lost:
|
|
344
|
+
// `[](url)` — the README badge — became `<a><a>…</a></a>`,
|
|
345
|
+
// `` `a **b** c` `` became `<code>a <b>b</b> c</code>`.
|
|
346
|
+
// A produced tag is therefore parked in `spans` behind a sentinel that no
|
|
347
|
+
// pattern below can match, and put back only after the last replace.
|
|
348
|
+
// `plain` unwraps a parked span to its text, which is how a link label
|
|
349
|
+
// that already holds a link (or code) is flattened instead of nested.
|
|
350
|
+
const spans = [];
|
|
351
|
+
const park = (html) => `\u0000${spans.push(html) - 1}\u0000`;
|
|
352
|
+
const plain = (s) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => spans[Number(i)].replace(/<[^>]+>/g, ''));
|
|
353
|
+
const bold = (s) => s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
|
353
354
|
row = row
|
|
354
|
-
|
|
355
|
-
.replace(
|
|
356
|
-
.replace(
|
|
355
|
+
// Code first: nothing inside a `code` span may become a tag.
|
|
356
|
+
.replace(/`([^`\n]+)`/g, (_, code) => park(`<code>${code}</code>`))
|
|
357
|
+
.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_, alt, url) => park(`<a href="${url}">${plain(alt).trim() || 'image'}</a>`))
|
|
358
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, label, url) => park(`<a href="${url}">${bold(plain(label)).trim() || 'link'}</a>`))
|
|
359
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, (_, label) => plain(label))
|
|
357
360
|
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
|
358
|
-
.replace(
|
|
361
|
+
.replace(/\u0000(\d+)\u0000/g, (_, i) => spans[Number(i)]);
|
|
359
362
|
out.push(row);
|
|
360
363
|
}
|
|
361
364
|
if (fence) {
|
|
@@ -374,12 +377,20 @@ export const markdownToTelegram = (text) => {
|
|
|
374
377
|
*/
|
|
375
378
|
const firstSection = (text) => {
|
|
376
379
|
const lines = text.replace(/<!--[\s\S]*?-->/g, '').split('\n');
|
|
377
|
-
|
|
380
|
+
// The cut is made at the next heading of the FIRST heading's level, not at
|
|
381
|
+
// any heading at all. `#{1,6}` treated a `###` subsection inside the first
|
|
382
|
+
// section as the second heading, so everything after it was silently
|
|
383
|
+
// dropped from a merged PR or a closed issue — the body the card exists to
|
|
384
|
+
// carry. A deeper heading belongs to the section it sits in and is kept.
|
|
385
|
+
let level = 0;
|
|
378
386
|
const kept = [];
|
|
379
387
|
for (const line of lines) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (
|
|
388
|
+
const m = line.match(/^\s*(#{1,6})\s+/);
|
|
389
|
+
if (m) {
|
|
390
|
+
if (level === 0) {
|
|
391
|
+
level = m[1].length;
|
|
392
|
+
}
|
|
393
|
+
else if (m[1].length <= level) {
|
|
383
394
|
break;
|
|
384
395
|
}
|
|
385
396
|
}
|
|
@@ -593,7 +604,7 @@ const twoBlocks = (run, change) => {
|
|
|
593
604
|
// the action text becomes the link: `Report: <a>Analytics for 12.08</a>`.
|
|
594
605
|
// `aside` is the one qualifier an identifier needs to be readable on its own —
|
|
595
606
|
// which day a report covers, which day its arrows are measured against. It
|
|
596
|
-
// rides in brackets
|
|
607
|
+
// rides in brackets on the TYPE WORD instead of taking a row of its own,
|
|
597
608
|
// because a row of its own reads as another fact about the subject rather than
|
|
598
609
|
// as part of the name.
|
|
599
610
|
const typeLine = (icon, type, action, url, aside) => {
|
|
@@ -601,7 +612,13 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
601
612
|
// template prints the word "null". That is how line 2 of a card became
|
|
602
613
|
// `ℹ️ null` — reachable through `--json` and through a direct call from JS,
|
|
603
614
|
// where there are no types.
|
|
604
|
-
|
|
615
|
+
// The qualifier sits ON the type word, before the colon — `Deploy (OK):`,
|
|
616
|
+
// `Report (20.08):` — never after the name. After the name it read as a
|
|
617
|
+
// remark about the title («Deploy to Beget (OK)») rather than about the
|
|
618
|
+
// event, and the owner asked on 03.09.2026 why the outcome trailed the
|
|
619
|
+
// title at all. `field`/`fieldLink` escape the label, so the raw aside is
|
|
620
|
+
// safe to fold into it.
|
|
621
|
+
const label = aside ? `${cap(type)} (${aside})` : type;
|
|
605
622
|
const name = action?.trim();
|
|
606
623
|
// No name: the card must not invent one. It used to write the word `open`
|
|
607
624
|
// into the identifier slot, so a report with a link and no title arrived as
|
|
@@ -611,11 +628,11 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
611
628
|
// something else instead. The type word itself takes the link, so nothing
|
|
612
629
|
// clickable is lost and nothing false is said.
|
|
613
630
|
if (!name) {
|
|
614
|
-
const bare = `<b>${esc(cap(
|
|
615
|
-
return `${icon} ${url ? `<a href="${esc(url)}">${bare}</a>` : bare}
|
|
631
|
+
const bare = `<b>${esc(cap(label))}</b>`;
|
|
632
|
+
return `${icon} ${url ? `<a href="${esc(url)}">${bare}</a>` : bare}`;
|
|
616
633
|
}
|
|
617
|
-
const line = url ? fieldLink(
|
|
618
|
-
return line === null ? `${icon} <b>${esc(cap(
|
|
634
|
+
const line = url ? fieldLink(label, url, name) : field(label, name);
|
|
635
|
+
return line === null ? `${icon} <b>${esc(cap(label))}</b>` : `${icon} ${line}`;
|
|
619
636
|
};
|
|
620
637
|
// `workflowUrl ?? url`: half the senders send the run link under the name
|
|
621
638
|
// `--url` — that name was in the package before and stayed in their calls.
|
|
@@ -655,11 +672,18 @@ const renderDeploy = (e) => {
|
|
|
655
672
|
// The run URL rides on the name (03.09.2026): the thing you read is the
|
|
656
673
|
// thing you tap. It spent three days in a `Source:` row at the bottom, and
|
|
657
674
|
// the owner asked what that row was for when the name was right there.
|
|
658
|
-
|
|
675
|
+
// The outcome word comes from the icon, not from a second reading of
|
|
676
|
+
// `status`: `fail ? 'Fail' : 'OK'` defaulted the opposite way from
|
|
677
|
+
// `iconFor` (`ok ? 🟢 : 🔴`), so any status outside {ok,fail} arrived as
|
|
678
|
+
// 🔴 `#fail` with the word `(OK)` beside it.
|
|
679
|
+
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), sourceUrl(e), outcomeAside(e)),
|
|
659
680
|
...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
681
|
+
// The body sits straight under the commit line, the people below it —
|
|
682
|
+
// the same shape as a PR or an issue. With `Author:` wedged between
|
|
683
|
+
// the two, the quote read as the author's words (03.09.2026).
|
|
660
684
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
661
|
-
|
|
662
|
-
|
|
685
|
+
bodyQuote(e.commitBody),
|
|
686
|
+
fieldPerson('Author', e.commitAuthor)
|
|
663
687
|
])
|
|
664
688
|
]);
|
|
665
689
|
};
|
|
@@ -667,6 +691,35 @@ const schedule = (expected, lastSeen, lastLabel) => {
|
|
|
667
691
|
const rows = [field('Expected', expected), field(lastLabel, lastSeen)].filter((r) => r !== null);
|
|
668
692
|
return rows.length > 0 ? ['', group('Schedule'), ...rows] : [];
|
|
669
693
|
};
|
|
694
|
+
/**
|
|
695
|
+
* `via` for display. The three words the senders actually use get an explicit
|
|
696
|
+
* spelling — `vps` upper-cased whole would read as a shout, `VPS` lower-cased
|
|
697
|
+
* as a typo — and anything else just gets its first letter raised, so a word
|
|
698
|
+
* nobody foresaw still prints as a word rather than being dropped.
|
|
699
|
+
*/
|
|
700
|
+
const VIA_NAMES = { mac: 'Mac', vps: 'VPS', actions: 'Actions' };
|
|
701
|
+
const viaName = (via) => VIA_NAMES[via.trim().toLowerCase()] ?? via.trim().charAt(0).toUpperCase() + via.trim().slice(1);
|
|
702
|
+
/**
|
|
703
|
+
* The bracket on the type word says HOW IT ENDED, in one word — the owner's
|
|
704
|
+
* decision of 03.09.2026: one slot, one meaning, on every type. It used to
|
|
705
|
+
* mean three different things at once — the outcome on deploy and CI, the day
|
|
706
|
+
* on a report, and (for one afternoon) the place of the run on a job — so the
|
|
707
|
+
* bracket could not be read without first knowing which card you were looking
|
|
708
|
+
* at. A job's outcome now reads the same way a deploy's does.
|
|
709
|
+
*/
|
|
710
|
+
const JOB_OUTCOME = {
|
|
711
|
+
ok: 'OK',
|
|
712
|
+
fail: 'Fail',
|
|
713
|
+
disabled: 'Off',
|
|
714
|
+
silent: 'Silent'
|
|
715
|
+
};
|
|
716
|
+
/**
|
|
717
|
+
* Where the job ran, as its own row directly under the type line. It spent one
|
|
718
|
+
* afternoon inside the bracket on the type word, which cost the bracket its
|
|
719
|
+
* single meaning; `Via:` is a fact ABOUT the run, and facts about the subject
|
|
720
|
+
* are what the rows under line 2 are for.
|
|
721
|
+
*/
|
|
722
|
+
const viaRow = (via) => via?.trim() ? field('Via', viaName(via)) : null;
|
|
670
723
|
const renderJob = (e) => {
|
|
671
724
|
const icon = iconFor(e);
|
|
672
725
|
const hasItems = (e.items ?? []).length > 0;
|
|
@@ -685,8 +738,30 @@ const renderJob = (e) => {
|
|
|
685
738
|
// The URL is on the name (03.09.2026). It went down to a `Source:` row in
|
|
686
739
|
// v2.1 so the pointer could be SEEN — and came back, because the row's
|
|
687
740
|
// only text was `workflow run`, the same two words on every card.
|
|
688
|
-
|
|
689
|
-
|
|
741
|
+
// `?? outcomeAside(e)` for the same reason `iconFor` defends itself: an
|
|
742
|
+
// untyped `--json` payload can carry a status outside the map or none at
|
|
743
|
+
// all, and `cap(undefined)` threw a TypeError instead of rendering a card.
|
|
744
|
+
// The word comes from the icon, so ❓ and `#unknown` say `(Unknown)`.
|
|
745
|
+
typeLine(icon, 'Job', e.job, sourceUrl(e), JOB_OUTCOME[e.status] ?? outcomeAside(e)),
|
|
746
|
+
// Where it ran comes first among the facts: it qualifies everything below
|
|
747
|
+
// it — the same reason, the same duration mean different things on the Mac
|
|
748
|
+
// and on the server.
|
|
749
|
+
viaRow(e.via),
|
|
750
|
+
// `aside` no longer has a bracket to ride in. It was always a sentence
|
|
751
|
+
// about WHY this card exists at all («reporting again»), which is what the
|
|
752
|
+
// Reason row says; it fills that row only when the sender left it empty,
|
|
753
|
+
// and is dropped rather than duplicating a real reason. The field stays in
|
|
754
|
+
// the event type — the senders that still pass it must not start failing.
|
|
755
|
+
// `filled`, not `??`: a sender that passes `note: ''` (an empty variable
|
|
756
|
+
// in a shell script) left the row empty, and `??` read that as a reason
|
|
757
|
+
// given — dropping the aside and the whole row with it.
|
|
758
|
+
reason('Reason', filled(e.note, e.aside)),
|
|
759
|
+
// How long the run took, in the sender's own words. It sits under the
|
|
760
|
+
// reason and above the timetable: the reason says what happened, this
|
|
761
|
+
// says what it cost, and only then comes when it is due again.
|
|
762
|
+
// Trimmed by `filled`: a whitespace-only `--took` printed a bare
|
|
763
|
+
// `Took:` with nothing after it.
|
|
764
|
+
field('Took', filled(e.duration)),
|
|
690
765
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
691
766
|
// The timetable is a different subject from this event: how often the task
|
|
692
767
|
// owes a sign of life and when it last gave one. It stood in a bare run
|
|
@@ -745,19 +820,23 @@ const renderCi = (e) => {
|
|
|
745
820
|
const icon = iconFor(e);
|
|
746
821
|
return join([
|
|
747
822
|
// The run URL is on the gate's name — `CI: <a>nightly</a>` — since 03.09.2026.
|
|
748
|
-
|
|
749
|
-
// `
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
823
|
+
// Same source as the icon — see the deploy card: the two readings of
|
|
824
|
+
// `status` defaulted opposite ways and the bracket could contradict both
|
|
825
|
+
// the icon and the tag.
|
|
826
|
+
typeLine(icon, 'CI', mechanism(e.workflowName, undefined), sourceUrl(e), outcomeAside(e)),
|
|
827
|
+
// No `Actor:` row (03.09.2026). It was redundant on every kind of run: on
|
|
828
|
+
// a push the actor IS the commit's author, printed a line above; on a
|
|
829
|
+
// scheduled run GitHub names the workflow's owner, the same person every
|
|
830
|
+
// night; only a hand-pressed button carries news, and the run link on
|
|
831
|
+
// line 2 shows that. `actor` stays in the event for old senders and is
|
|
832
|
+
// simply not printed.
|
|
756
833
|
...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
757
|
-
|
|
834
|
+
// Commit line, its body, then the people — same shape as deploy, PR
|
|
835
|
+
// and issue; a people row between a title and its quote makes the
|
|
836
|
+
// quote read as that person's words.
|
|
758
837
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
759
|
-
|
|
760
|
-
|
|
838
|
+
bodyQuote(e.commitBody),
|
|
839
|
+
fieldPerson('Author', e.commitAuthor)
|
|
761
840
|
])
|
|
762
841
|
]);
|
|
763
842
|
};
|
|
@@ -794,11 +873,34 @@ const renderCi = (e) => {
|
|
|
794
873
|
* twenty lines under a title that could not be tapped. With no url the number
|
|
795
874
|
* still prints, plain. With no title the line ends at the number.
|
|
796
875
|
*/
|
|
797
|
-
const numberedLine = (icon, type, number, title, url) => {
|
|
876
|
+
const numberedLine = (icon, type, number, title, url, outcome) => {
|
|
798
877
|
const id = url ? `<a href="${esc(firstLine(url))}">#${number}</a>` : `#${number}`;
|
|
799
878
|
const name = title?.trim() ? ` · ${esc(firstLine(title.trim()))}` : '';
|
|
800
|
-
|
|
879
|
+
// The bracket goes on the TYPE WORD, before the number — the same slot it
|
|
880
|
+
// takes on `Deploy (OK):`, so one glance finds the outcome on any card.
|
|
881
|
+
const label = outcome ? `${type} (${outcome})` : type;
|
|
882
|
+
return `${icon} <b>${esc(label)}</b> ${id}${name}`;
|
|
801
883
|
};
|
|
884
|
+
/**
|
|
885
|
+
* How a pull request or an issue ended, in one word. An action nobody
|
|
886
|
+
* foresaw — a new GitHub verb, or a missing one in an untyped `--json`
|
|
887
|
+
* payload — takes the word the icon already implies, `Unknown`; it used to
|
|
888
|
+
* take the literal `Info`, which is not an outcome and contradicted both the
|
|
889
|
+
* ❓ on the same line and the `#unknown` tag above it.
|
|
890
|
+
*/
|
|
891
|
+
const PR_OUTCOME = {
|
|
892
|
+
opened: 'Opened',
|
|
893
|
+
approved: 'Approved',
|
|
894
|
+
changes_requested: 'Changes',
|
|
895
|
+
merged: 'Merged',
|
|
896
|
+
closed: 'Closed'
|
|
897
|
+
};
|
|
898
|
+
const ISSUE_OUTCOME = {
|
|
899
|
+
opened: 'Opened',
|
|
900
|
+
assigned: 'Assigned',
|
|
901
|
+
closed: 'Closed'
|
|
902
|
+
};
|
|
903
|
+
const outcomeWord = (table, e) => table[e.action] ?? outcomeAside(e);
|
|
802
904
|
// The people come BEFORE the text, and the text comes only when it is the
|
|
803
905
|
// news. An `assigned` card carries one new fact — who took it — and it used to
|
|
804
906
|
// sit dead last, under the issue's entire description: the owner read a card
|
|
@@ -836,7 +938,7 @@ const prBody = (action, body) => {
|
|
|
836
938
|
// old order, title then Author then Assignee then finally the body — "why
|
|
837
939
|
// does the assignee cut apart what should be inseparable?"
|
|
838
940
|
const renderPr = (e) => join([
|
|
839
|
-
numberedLine(iconFor(e), 'PR', e.number, e.title, e.url),
|
|
941
|
+
numberedLine(iconFor(e), 'PR', e.number, e.title, e.url, outcomeWord(PR_OUTCOME, e)),
|
|
840
942
|
prBody(e.action, e.body),
|
|
841
943
|
e.body ? '' : null,
|
|
842
944
|
fieldPerson('Author', e.author),
|
|
@@ -851,7 +953,7 @@ const renderPr = (e) => join([
|
|
|
851
953
|
// Whole on opened and assigned (the text is still the news, or the person who
|
|
852
954
|
// took it needs the whole brief); the first section on closed — see prBody.
|
|
853
955
|
const renderIssue = (e) => join([
|
|
854
|
-
numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url),
|
|
956
|
+
numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url, outcomeWord(ISSUE_OUTCOME, e)),
|
|
855
957
|
markdownQuote(e.body, e.action !== 'closed'),
|
|
856
958
|
e.body ? '' : null,
|
|
857
959
|
fieldPerson('Author', e.author),
|
|
@@ -868,35 +970,55 @@ const renderIssue = (e) => join([
|
|
|
868
970
|
const renderIncident = (e) => {
|
|
869
971
|
const findings = bullets(e.items, false);
|
|
870
972
|
return join([
|
|
871
|
-
|
|
973
|
+
// The bracket on an incident names WHERE it burns, not how it ended —
|
|
974
|
+
// it has one ending. `Incident (Vault):`, `Incident (Session):`; with no
|
|
975
|
+
// word from the sender, the project name stands in.
|
|
976
|
+
// `filled`, not `??`: `scope: ''` and `scope: ' '` are a sender that
|
|
977
|
+
// named no place, and `??` let the first past the project fallback (no
|
|
978
|
+
// bracket at all) and the second into a visibly empty one.
|
|
979
|
+
typeLine(iconFor(e), 'Incident', e.title, e.url, filled(e.scope) ?? cap(e.project)),
|
|
872
980
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
981
|
+
// The rows below came from the `session` card when it was folded in
|
|
982
|
+
// (03.09.2026), in the order they had there: which copy, what the guard
|
|
983
|
+
// measured, then his own opening line as a quote, then the way out.
|
|
984
|
+
field('Project', e.workdir),
|
|
985
|
+
reason('Reason', e.reason),
|
|
873
986
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
874
987
|
findings.length > 0 ? '' : null,
|
|
875
|
-
...findings
|
|
988
|
+
...findings,
|
|
989
|
+
e.opened ? '' : null,
|
|
990
|
+
quoted('Opened with', e.opened),
|
|
991
|
+
e.command ? '' : null,
|
|
992
|
+
...fieldRun(e.command, e.commandNote)
|
|
876
993
|
// `Log:` moved to the pointer block `render` appends — see renderJob.
|
|
877
994
|
]);
|
|
878
995
|
};
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
996
|
+
/**
|
|
997
|
+
* A session has no name of its own, so the incident's title is built out of
|
|
998
|
+
* what happened to it: `burning the limit` → `Claude session is burning the
|
|
999
|
+
* limit`. That is the whole of what the separate `session` card ever said
|
|
1000
|
+
* differently — every other row it had is an incident row now.
|
|
1001
|
+
*
|
|
1002
|
+
* The 36-character id is still not printed: he cannot type it, cannot search
|
|
1003
|
+
* it and cannot act on it. It reaches the card only inside the `rm` command at
|
|
1004
|
+
* the bottom, which is the one place it is of any use.
|
|
1005
|
+
*/
|
|
1006
|
+
export const sessionTitle = (action) => {
|
|
1007
|
+
const what = action?.trim();
|
|
1008
|
+
return what ? `Claude session is ${what}` : 'Claude session is in trouble';
|
|
1009
|
+
};
|
|
1010
|
+
/**
|
|
1011
|
+
* The folded `session` type (03.09.2026): accepted, never rendered as itself.
|
|
1012
|
+
* It is turned into the incident it always was and handed to the incident
|
|
1013
|
+
* renderer, so there is ONE card shape and no second copy to keep in step.
|
|
1014
|
+
*/
|
|
1015
|
+
const asIncident = (e) => ({
|
|
1016
|
+
...e,
|
|
1017
|
+
type: 'incident',
|
|
1018
|
+
scope: 'Session',
|
|
1019
|
+
title: sessionTitle(e.action)
|
|
1020
|
+
});
|
|
1021
|
+
const renderSession = (e) => renderIncident(asIncident(e));
|
|
900
1022
|
const renderHeartbeatMiss = (e) => {
|
|
901
1023
|
const icon = iconFor(e);
|
|
902
1024
|
return join([
|
|
@@ -905,10 +1027,10 @@ const renderHeartbeatMiss = (e) => {
|
|
|
905
1027
|
// repository builds this event any more — the silence watchdog sends an
|
|
906
1028
|
// ordinary job with `--status silent` — but a machine still running the old
|
|
907
1029
|
// copy of that watchdog can, and the card it gets must obey the template.
|
|
908
|
-
//
|
|
909
|
-
// it
|
|
910
|
-
//
|
|
911
|
-
typeLine(icon, 'Heartbeat', e.job),
|
|
1030
|
+
// The bracket takes an outcome word, the same as a job card does — and
|
|
1031
|
+
// it takes one of the job's own words: `Silent`, never `miss`, which is
|
|
1032
|
+
// not one of the words the outcome is allowed to be.
|
|
1033
|
+
typeLine(icon, 'Heartbeat', e.job, undefined, e.recovered ? 'OK' : 'Silent'),
|
|
912
1034
|
field('Reason', e.note),
|
|
913
1035
|
...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
|
|
914
1036
|
]);
|
|
@@ -948,7 +1070,8 @@ const TYPE_TAG = {
|
|
|
948
1070
|
pr: 'pr',
|
|
949
1071
|
issue: 'issue',
|
|
950
1072
|
incident: 'incident',
|
|
951
|
-
session
|
|
1073
|
+
// Folded into incident on 03.09.2026 — `#session` is never printed again.
|
|
1074
|
+
session: 'incident',
|
|
952
1075
|
heartbeat_miss: 'heartbeat'
|
|
953
1076
|
};
|
|
954
1077
|
/**
|
|
@@ -1015,6 +1138,25 @@ export const OUTCOME_TAG = {
|
|
|
1015
1138
|
[ICON.info]: 'info'
|
|
1016
1139
|
};
|
|
1017
1140
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
1141
|
+
/**
|
|
1142
|
+
* The bracket word for a status or an action no outcome table knows — a new
|
|
1143
|
+
* GitHub verb, a `--json` payload with a typo or with no status at all. It is
|
|
1144
|
+
* read off the SAME icon the card already shows, so line 2 can never
|
|
1145
|
+
* contradict line 1 or the third tag: an unmapped value renders ❓ `#unknown`
|
|
1146
|
+
* and now says `(Unknown)` too, where it used to say `(OK)` or `(Info)` — the
|
|
1147
|
+
* exact opposite of the icon beside it.
|
|
1148
|
+
*
|
|
1149
|
+
* `info` gets no word: nothing was decided on that card, and there is no
|
|
1150
|
+
* honest outcome to name. A missing bracket is a gap; a wrong one is a lie.
|
|
1151
|
+
*/
|
|
1152
|
+
const FALLBACK_OUTCOME = {
|
|
1153
|
+
ok: 'OK',
|
|
1154
|
+
fail: 'Fail',
|
|
1155
|
+
off: 'Off',
|
|
1156
|
+
unknown: 'Unknown',
|
|
1157
|
+
info: undefined
|
|
1158
|
+
};
|
|
1159
|
+
const outcomeAside = (e) => FALLBACK_OUTCOME[outcomeTag(e)];
|
|
1018
1160
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
1019
1161
|
/**
|
|
1020
1162
|
* Rule S (v2.1, amended 03.09.2026): the card says where to verify it. A
|
package/package.json
CHANGED