@mikitasazan/notify 1.11.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-flags.js CHANGED
@@ -11,7 +11,7 @@
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-body', 'commit-title', 'commit-url', 'detail',
14
+ 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url', 'detail',
15
15
  'expected', 'filename', 'item',
16
16
  'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
17
17
  'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
package/dist/cli.js CHANGED
@@ -103,7 +103,13 @@ const num = (key) => {
103
103
  return n;
104
104
  };
105
105
  /**
106
- * `--item "text"` or `--item "text|https://link"`.
106
+ * `--item "text"`, `--item "text|https://link"`, or, since 26.08.2026,
107
+ * `--item "LABEL::text"` (and `LABEL::text|https://link`) for a bold label
108
+ * in front of the row — the same shape a search query's `facts` render,
109
+ * so a list of independent findings (vault's BAD/STALE/DIVERGED lines) does
110
+ * not have to smuggle its own heading inside the text. `::` and not `|`,
111
+ * which the link already owns, and not `:`, which shows up inside real
112
+ * findings ("STALE IN ARCHIVE: ssh-keys.tar.gz.age" already has one).
107
113
  *
108
114
  * An item's group name cannot be passed the same way `--stat` does it: the
109
115
  * bar here is already taken by the link. So `--item-group "Red checks"` is
@@ -114,9 +120,12 @@ const num = (key) => {
114
120
  const items = () => {
115
121
  const name = flags.get('item-group')?.[0];
116
122
  return (flags.get('item') ?? []).map((raw) => {
117
- const idx = raw.lastIndexOf('|');
118
- const base = idx === -1 ? { text: raw } : { text: raw.slice(0, idx), url: raw.slice(idx + 1) };
119
- return name ? { ...base, group: name } : base;
123
+ const labelEnd = raw.indexOf('::');
124
+ const label = labelEnd === -1 ? undefined : raw.slice(0, labelEnd);
125
+ const rest = labelEnd === -1 ? raw : raw.slice(labelEnd + 2);
126
+ const idx = rest.lastIndexOf('|');
127
+ const base = idx === -1 ? { text: rest } : { text: rest.slice(0, idx), url: rest.slice(idx + 1) };
128
+ return { ...base, ...(label ? { label } : {}), ...(name ? { group: name } : {}) };
120
129
  });
121
130
  };
122
131
  /**
@@ -228,6 +237,7 @@ else {
228
237
  commitUrl: one('commit-url'),
229
238
  commitTitle: one('commit-title'),
230
239
  commitBody: one('commit-body'),
240
+ commitAuthor: one('commit-author'),
231
241
  workflowUrl: one('workflow-url'),
232
242
  workflowName: one('workflow-name'),
233
243
  url: one('url'),
@@ -279,6 +289,7 @@ else {
279
289
  commitUrl: one('commit-url'),
280
290
  commitTitle: one('commit-title'),
281
291
  commitBody: one('commit-body'),
292
+ commitAuthor: one('commit-author'),
282
293
  actor: one('actor'),
283
294
  note: one('note'),
284
295
  workflowUrl: one('workflow-url'),
@@ -333,6 +344,7 @@ else {
333
344
  project: project(),
334
345
  title: one('title') ?? '(no title)',
335
346
  detail: one('detail'),
347
+ items: items(),
336
348
  logs: one('logs'),
337
349
  url: one('url')
338
350
  };
@@ -357,8 +369,10 @@ else {
357
369
  type: 'report',
358
370
  project: project(),
359
371
  title: one('title') ?? '(no title)',
360
- // A file card has no period; the caption is its title.
361
- lines: []
372
+ aside: one('aside') ?? one('period'),
373
+ lines: pairs('line'),
374
+ items: items(),
375
+ url: one('url')
362
376
  };
363
377
  break;
364
378
  default:
package/dist/events.d.ts CHANGED
@@ -12,9 +12,10 @@
12
12
  */
13
13
  export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault' | 'mac-config' | 'alitools';
14
14
  /**
15
- * A stable machine key for the task — the last line of every card, shaped
16
- * as `#key` (with no project name: the card already sits in its own
17
- * project's forum — `targets()` never sends it to someone else's). The
15
+ * A stable machine key for the task — the instance tag on the FIRST line of
16
+ * every card, shaped as `#key` (with no project name: the card already sits
17
+ * in its own project's forum — `targets()` never sends it to someone else's).
18
+ * The
18
19
  * daily parser uses it to check "is this 🔴 already closed by a later card
19
20
  * with the same key?" without comparing human wording, which changes. It
20
21
  * is optional: without it, the key is derived from the type and the title
@@ -57,11 +58,19 @@ type Keyed = {
57
58
  * 🆕 came out today, 🔁 came out of the queue, ⚠ did not come out at all.
58
59
  * The icon was doing a heading's job.
59
60
  */
61
+ /**
62
+ * `facts` — sub-rows under an item, indented, each `label: value`. For an
63
+ * item whose own value is not one number but several (a search query with
64
+ * its own clicks AND position) — a nested list, not a sentence stuffed into
65
+ * `text`: "0 clicks, pos. 55" was two facts hand-joined into a string, the
66
+ * exact shape that gets pulled apart into its own field everywhere else.
67
+ */
60
68
  export type Item = {
61
69
  text: string;
62
70
  url?: string;
63
71
  label?: string;
64
72
  group?: string;
73
+ facts?: Array<[label: string, value: string | number]>;
65
74
  };
66
75
  export type NotifyEvent = Keyed & (
67
76
  /** Shipping code to the server. */
@@ -72,12 +81,14 @@ export type NotifyEvent = Keyed & (
72
81
  commit?: string;
73
82
  /** A link to the commit — the "commit" row becomes clickable. */
74
83
  commitUrl?: string;
75
- /** The commit's title — renders as the `Title:` field, the body follows as a quote. */
84
+ /** The commit's title — joins the `Commit:` row after the hash, the body follows as a quote. */
76
85
  commitTitle?: string;
77
86
  /** The commit's body, if there is one — the same quote shape as the title. */
78
87
  commitBody?: string;
88
+ /** The commit's author (GitHub login) — the `Author:` row links to their profile. */
89
+ commitAuthor?: string;
79
90
  workflowUrl?: string;
80
- /** The run's name, for the link's visible text (defaults to `open`). */
91
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
81
92
  workflowName?: string;
82
93
  url?: string;
83
94
  /**
@@ -164,7 +175,7 @@ export type NotifyEvent = Keyed & (
164
175
  */
165
176
  logs?: string;
166
177
  workflowUrl?: string;
167
- /** The run's name, for the link's visible text (defaults to `open`). */
178
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
168
179
  workflowName?: string;
169
180
  /**
170
181
  * A fallback name for the run link: half the senders send it as
@@ -185,19 +196,25 @@ export type NotifyEvent = Keyed & (
185
196
  * accepts `--period` for it.
186
197
  */
187
198
  aside?: string;
188
- /** Empty/not passed when `groups` is used — the two kinds of report are not mixed in one event. */
199
+ /**
200
+ * Renders even when `groups` is also set — a report's headline numbers
201
+ * (Pages, People, Impressions…) sit above the grouped section, not
202
+ * replaced by it. `lines` and `groups` answer different questions:
203
+ * "what changed" and "what's in each list".
204
+ */
189
205
  lines?: Array<[label: string, value: string | number, group?: string]>;
190
206
  /**
191
207
  * A list of items with links — for task digests, where the value is
192
208
  * in the names themselves, not in a number. Renders as a separate
193
- * block after `lines`.
209
+ * block after `lines`. Ignored (not merged, not an error) when `groups`
210
+ * is also set — no live sender sets both today.
194
211
  */
195
212
  items?: Item[];
196
213
  /**
197
214
  * Named groups (a task board: Ready/In Progress/Not on the board;
198
- * analytics: Metrics/Links) — each with its own heading and list of
199
- * items. Replaces `lines`/`items` when set: different reports use
200
- * either the flat form or groups, never both at once.
215
+ * analytics: Top search queries) — each with its own heading and list
216
+ * of items. Replaces `items` when set, but `lines` still renders above
217
+ * it see that field's own doc.
201
218
  */
202
219
  groups?: Array<{
203
220
  name: string;
@@ -214,10 +231,12 @@ export type NotifyEvent = Keyed & (
214
231
  commit?: string;
215
232
  /** A link to the commit — the hash becomes clickable. */
216
233
  commitUrl?: string;
217
- /** The commit's title (subject) — a separate `Title:` field, not a quote. */
234
+ /** The commit's title (subject) — joins the `Commit:` row after the hash, not a quote. */
218
235
  commitTitle?: string;
219
236
  /** The commit's body (after the subject) — the same quote shape as the title. */
220
237
  commitBody?: string;
238
+ /** The commit's author (GitHub login) — the `Author:` row links to their profile. Distinct from `actor`: on a scheduled run `actor` is whoever is on duty to fix it, not who wrote the code. */
239
+ commitAuthor?: string;
221
240
  actor?: string;
222
241
  /**
223
242
  * Why this run happened, when there is no commit to point at: a nightly
@@ -226,7 +245,7 @@ export type NotifyEvent = Keyed & (
226
245
  note?: string;
227
246
  /** A link to the run (workflow run) — separate from `url`, a fallback for `workflowUrl`. */
228
247
  workflowUrl?: string;
229
- /** The run's name, for the link's visible text (defaults to `open`). */
248
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
230
249
  workflowName?: string;
231
250
  url?: string;
232
251
  }
@@ -242,7 +261,12 @@ export type NotifyEvent = Keyed & (
242
261
  action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
243
262
  number: number;
244
263
  title: string;
245
- /** PR description — quoted on its own; the title is the `Title:` field above it. */
264
+ /**
265
+ * On `opened`: the PR's own description. On `approved`/
266
+ * `changes_requested`: the reviewer's comment, not the PR's
267
+ * description again — quoted on its own either way; the title joins
268
+ * the `PR:` type line above it, there is no separate field for it.
269
+ */
246
270
  body?: string;
247
271
  author?: string;
248
272
  reviewer?: string;
@@ -255,7 +279,7 @@ export type NotifyEvent = Keyed & (
255
279
  action: 'opened' | 'assigned' | 'closed';
256
280
  number: number;
257
281
  title: string;
258
- /** The issue's body — a quote under the `Title:` field, separate from the title. */
282
+ /** The issue's body — a quote under the `Issue:` type line, which already carries the title. */
259
283
  body?: string;
260
284
  author?: string;
261
285
  assignee?: string;
@@ -266,7 +290,16 @@ export type NotifyEvent = Keyed & (
266
290
  type: 'incident';
267
291
  project: Project;
268
292
  title: string;
293
+ /** One free-form paragraph — a diagnosis that is genuinely one thought, not several findings glued by newlines. */
269
294
  detail?: string;
295
+ /**
296
+ * Several INDEPENDENT findings (a self-check emitting up to three
297
+ * unrelated diagnostic lines, each starting with its own marker word
298
+ * — `BAD`, `STALE`, `DIVERGED`) go here, not into `detail`: a list
299
+ * squeezed into one blockquote read as a wall of text with no
300
+ * category, the marker words doing a label's job inside a value.
301
+ */
302
+ items?: Item[];
270
303
  /** A local path to the logs (not a URL — renders monospaced, to copy, not to click). */
271
304
  logs?: string;
272
305
  url?: string;
@@ -287,7 +320,7 @@ export type NotifyEvent = Keyed & (
287
320
  project: Project;
288
321
  /** What happened, as the second line reads it: `Session: burning the limit`. */
289
322
  action: string;
290
- /** The session's own id — the identifier field, first, as everywhere else. */
323
+ /** 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`. */
291
324
  id?: string;
292
325
  /** Working directory name, when several sessions opened with a similar line. */
293
326
  workdir?: string;
@@ -320,7 +353,7 @@ export type NotifyEvent = Keyed & (
320
353
  expected?: string;
321
354
  /** The task reported in again — same type, a green card instead of a red one, the key (for matching) does not change. */
322
355
  recovered?: boolean;
323
- /** A ready-made reason sentence; without it, one is built from lastSeen/expected. */
356
+ /** A ready-made reason sentence. Without it there is no `Reason:` row at all — `lastSeen`/`expected` still print, under their own `Schedule` group. */
324
357
  note?: string;
325
358
  });
326
359
  export type EventType = NotifyEvent['type'];
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
@@ -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('>', openTag) === -1) {
70
+ if (openTag !== -1 && cut.slice(openTag, end).indexOf('>') === -1) {
66
71
  end = openTag;
67
72
  }
68
73
  const amp = cut.lastIndexOf('&', end - 1);
@@ -140,19 +145,43 @@ const fieldLink = (label, url, text) => {
140
145
  ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
141
146
  : field(label, text);
142
147
  };
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
148
  /** A monospace field — a path/command to copy, not a link. */
155
149
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
150
+ /**
151
+ * A person field — Author, Assignee, Reviewer. Every one of them is a
152
+ * GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
153
+ * on the GitHub API object), and every GitHub login is a profile at one fixed
154
+ * address: `github.com/<login>`. The owner: "автор должен вести на страницу
155
+ * автора" — a name is an identifier like a commit hash or a PR number, and
156
+ * every other identifier on the card is a link.
157
+ */
158
+ // `firstLine`, same as `fieldLink`: a login with an embedded `\n` (found by
159
+ // Codex/GLM review) would otherwise land a raw newline inside the `href`
160
+ // itself, not just the link text — every other field guards against a
161
+ // multi-line value, this one didn't.
162
+ const fieldPerson = (label, login) => {
163
+ if (!login) {
164
+ return null;
165
+ }
166
+ const oneLine = firstLine(login);
167
+ return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
168
+ };
169
+ /**
170
+ * `Actor` on a CI card is a Telegram handle, not a GitHub login
171
+ * (`nightly.yml`, step "Кто чинит" — "по «@chelsnebes» приходит уведомление
172
+ * тому, кто чинит, по «mikitasazan» — нет"), so it links to Telegram, not
173
+ * GitHub: `github.com/@chelsnebes` would open a page that does not exist.
174
+ * Telegram DOES auto-link a bare `@handle` on its own, but the owner asked
175
+ * for an explicit link like every other identifier on the card, not an
176
+ * implicit one riding on a client behavior he cannot see from here.
177
+ */
178
+ const fieldTelegram = (label, handle) => {
179
+ if (!handle) {
180
+ return null;
181
+ }
182
+ const oneLine = firstLine(handle);
183
+ return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(oneLine.replace(/^@/, ''))}">${esc(oneLine)}</a>`;
184
+ };
156
185
  /**
157
186
  * A row that asks something FROM THE OWNER, rather than reports a fact. It
158
187
  * already stood last, set off by a blank line, and still read as an
@@ -178,13 +207,25 @@ const fieldRun = (value, why) => {
178
207
  };
179
208
  /** A group heading: italic + underline, no bold, no colon. */
180
209
  const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
181
- /** An item inside a group: `<b>label:</b> <a>text</a>` — or a plain bulleted/numbered row with no label. */
210
+ /**
211
+ * An item inside a group: `<b>label:</b> <a>text</a>` — or a plain
212
+ * bulleted/numbered row with no label. `facts`, when the item carries them,
213
+ * print as indented `label: value` rows underneath — a nested list, one
214
+ * item with several facts of its own, the way a search query has both a
215
+ * click count and a position.
216
+ */
182
217
  const groupItem = (it, index, numbered) => {
183
218
  const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
184
- if (it.label) {
185
- return `<b>${esc(it.label)}:</b> ${linked}`;
219
+ const head = it.label
220
+ ? `<b>${esc(cap(it.label))}:</b> ${linked}`
221
+ : numbered
222
+ ? `${index + 1}. ${linked}`
223
+ : `• ${linked}`;
224
+ if (!it.facts || it.facts.length === 0) {
225
+ return head;
186
226
  }
187
- return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
227
+ const sub = it.facts.map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
228
+ return [head, ...sub].join('\n');
188
229
  };
189
230
  // A long explanation (a note, incident details) — as a quote: in Telegram
190
231
  // that is a bar on the left and a light indent, reading as "details," not as
@@ -206,15 +247,16 @@ const note = (text) => {
206
247
  * the card says it nowhere. The caption stands on its own line, because
207
248
  * the text itself does not fit in a field: a field holds one line and cuts
208
249
  * it.
250
+ *
251
+ * The caption is a bold field label with nothing after the colon, not the
252
+ * group() heading (italic-underline). It was the group heading first, and
253
+ * the owner read that and said it did not look like a category — correctly:
254
+ * a group is a heading over a LIST, and this caption sits over one quote,
255
+ * never more than one. A bold label with no value on the line is the same
256
+ * shape `Title:` already has right before a PR's body — the reader has
257
+ * already seen this exact pattern mean "what follows is quoted text."
209
258
  */
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;
259
+ const quoted = (label, text) => text ? `<b>${esc(cap(label))}:</b>\n${note(text)}` : null;
218
260
  /**
219
261
  * Assembles the card. A blank line here marks a block change, not
220
262
  * indentation: two in a row mean an empty block, a leading one means a
@@ -421,14 +463,12 @@ const typeLine = (icon, type, action, url, aside) => {
421
463
  /**
422
464
  * What to call the thing that ran. The workflow's own name first — it is the
423
465
  * only text here that identifies THIS run. Then the caller's own word for the
424
- * mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
425
- * link exists: losing the link to the logs on a red card is the one loss this
426
- * format cannot afford, and a row that says nothing is still better than a
427
- * card with nowhere to click. No live sender reaches that last resort — the
428
- * GitHub Action always fills the workflow name, and the hand-run scripts send
429
- * no run link at all.
466
+ * mechanism (`manual, from the Mac`). No third fallback: a row that says
467
+ * nothing distinctive is worse than not printing a name at all, and
468
+ * `typeLine` already handles the case with no name a bare `<b>Deploy</b>`,
469
+ * still linked when a run URL exists.
430
470
  */
431
- const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
471
+ const mechanism = (workflowName, via) => workflowName ?? via;
432
472
  // The name of what ran sits WITH the type line, not eight lines below it.
433
473
  // `Deploy: fail` and `by what means it ran` answer one question, and the owner
434
474
  // read the two rows as unrelated things. It used to be one fact split in two:
@@ -445,12 +485,17 @@ const renderDeploy = (e) => {
445
485
  const icon = iconFor(e);
446
486
  const runUrl = e.workflowUrl ?? e.url;
447
487
  return join([
448
- // The name of what shipped the deploy sits on the type line. The outcome
449
- // is already said by the icon and the third tag; there is nothing to
450
- // repeat in words, and it is the same law a job and a report follow. The
451
- // `Via` row is gone: it used to carry this same name one floor below.
452
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
453
- ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
488
+ // The name of what shipped the deploy sits on the type line, the outcome
489
+ // in parens beside it icon and tag alone were judged, live, not to be
490
+ // enough: a plain 🔴 next to a workflow name still read as "something
491
+ // happened," not "it failed," on a screen small enough to lose the color.
492
+ // The `Via` row is gone: it used to carry this same name one floor below.
493
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), runUrl, e.status === 'fail' ? 'Fail' : 'OK'),
494
+ ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [
495
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
496
+ fieldPerson('Author', e.commitAuthor),
497
+ bodyQuote(e.commitBody)
498
+ ])
454
499
  ]);
455
500
  };
456
501
  const schedule = (expected, lastSeen, lastLabel) => {
@@ -523,16 +568,29 @@ const renderReport = (e) => {
523
568
  ...items
524
569
  ]);
525
570
  };
526
- // Same law as the deploy card, one row up: what ran is named beside the type
527
- // line and carries the link to its run. The label is `Check` and not `Via`
528
- // because here the name answers WHICH gate spoke `nightly`, `Quality`
529
- // while on a deploy it answers by what means the code was shipped.
571
+ // Same law as the deploy card: what ran joins the type line itself and
572
+ // carries the link to its run `CI: nightly`, not a separate `Check:`/`Via:`
573
+ // row. There is no separate label here at all: on deploy the mechanism
574
+ // answers by what means the code was shipped, on CI it answers WHICH gate
575
+ // spoke (`nightly`, `Quality`) — but both are the name on the type line.
530
576
  const renderCi = (e) => {
531
577
  const icon = iconFor(e);
532
578
  const runUrl = e.workflowUrl ?? e.url;
533
579
  return join([
534
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
535
- ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
580
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined), runUrl, e.status === 'fail' ? 'Fail' : 'OK'),
581
+ // `Actor` used to be read as "who wrote the commit," and on most runs it
582
+ // is — `github.actor` for a push IS the person who pushed. It stops being
583
+ // that on a scheduled run: arvent's nightly rewrites it to whoever is on
584
+ // duty to fix a red run, which can be someone other than the commit's
585
+ // author. So Actor answers "who is responsible for this run," `Author`
586
+ // below the commit answers "who wrote this code" — two different people
587
+ // on a nightly card, the same person everywhere else.
588
+ ...twoBlocks([field('Reason', e.note)], [
589
+ fieldTelegram('Actor', e.actor),
590
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
591
+ fieldPerson('Author', e.commitAuthor),
592
+ bodyQuote(e.commitBody)
593
+ ])
536
594
  ]);
537
595
  };
538
596
  // A pull request and an issue are identified the way GitHub itself identifies
@@ -548,22 +606,39 @@ const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
548
606
  // about someone taking issue #312 and asked who, because he never got that far.
549
607
  //
550
608
  // The description is the news exactly once, when the thing is opened. On
551
- // assigned, closed, merged or a review verdict it is text he has already read,
552
- // and it buries the one line he came for.
553
- const opening = (action, body) => action === 'opened' ? bodyQuote(body) : null;
609
+ // assigned, closed or merged it is text he has already read, and it buries
610
+ // the one line he came for.
611
+ //
612
+ // A review verdict is the one exception: on `approved`/`changes_requested`
613
+ // `body` is not the PR's description any more — the sender puts the
614
+ // REVIEWER'S OWN comment there, which is new text he has not seen. "Verdict:
615
+ // changes_requested, then nothing" was the owner's complaint: a verdict with
616
+ // no comment attached said less than the review itself did.
617
+ const VERDICT = new Set(['approved', 'changes_requested']);
618
+ const opening = (action, body) => action === 'opened' || VERDICT.has(action) ? bodyQuote(body) : null;
619
+ // The body is what the title stands for — it sits directly under the name,
620
+ // with nothing between them. The people come after, consolidated in one
621
+ // place, never splitting the title from what it names: the owner on the
622
+ // old order, title then Author then Assignee then finally the body — "why
623
+ // does the assignee cut apart what should be inseparable?"
554
624
  const renderPr = (e) => join([
555
625
  typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
556
- field('Author', e.author),
557
- field('Reviewer', e.reviewer),
558
- e.action === 'opened' && e.body ? '' : null,
559
- opening(e.action, e.body)
626
+ opening(e.action, e.body),
627
+ (e.action === 'opened' || VERDICT.has(e.action)) && e.body ? '' : null,
628
+ fieldPerson('Author', e.author),
629
+ fieldPerson('Reviewer', e.reviewer)
560
630
  ]);
631
+ // Unlike a PR, an issue's `body` means one thing on every action — its own
632
+ // description, never someone else's verdict — so it prints whenever it is
633
+ // there, not only on `opened`. The owner: hiding it on `assigned` read as
634
+ // inconsistent with the same card type showing it moments earlier — his call
635
+ // to keep, 26.08.2026, over the earlier "he already saw it" reasoning.
561
636
  const renderIssue = (e) => join([
562
637
  typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
563
- field('Author', e.author),
564
- field('Assignee', e.assignee),
565
- e.action === 'opened' && e.body ? '' : null,
566
- opening(e.action, e.body)
638
+ bodyQuote(e.body),
639
+ e.body ? '' : null,
640
+ fieldPerson('Author', e.author),
641
+ fieldPerson('Assignee', e.assignee)
567
642
  ]);
568
643
  // The incident's own title IS line 2, exactly as an issue's is. It used to say
569
644
  // the word `open` there — which the 🚨 already says, and no other card repeats
@@ -573,12 +648,17 @@ const renderIssue = (e) => join([
573
648
  // log path). It used to go through `field`, which keeps only the first line, so
574
649
  // every alarm this package ever sent arrived gutted. It is quoted now, the same
575
650
  // shape a commit body takes.
576
- const renderIncident = (e) => join([
577
- typeLine(iconFor(e), 'Incident', e.title, e.url),
578
- e.detail && e.detail !== e.title ? note(e.detail) : null,
579
- e.logs ? '' : null,
580
- fieldCode('Log', e.logs)
581
- ]);
651
+ const renderIncident = (e) => {
652
+ const findings = bullets(e.items, false);
653
+ return join([
654
+ typeLine(iconFor(e), 'Incident', e.title, e.url),
655
+ e.detail && e.detail !== e.title ? note(e.detail) : null,
656
+ findings.length > 0 ? '' : null,
657
+ ...findings,
658
+ e.logs ? '' : null,
659
+ fieldCode('Log', e.logs)
660
+ ]);
661
+ };
582
662
  // A session in trouble. Same law as every other card: identifier first, then
583
663
  // the facts as fields, then his own words as a quote — never as a field, which
584
664
  // keeps one line and clipped the name of the very session the card is about.
package/dist/trend.d.ts CHANGED
@@ -10,15 +10,18 @@
10
10
  * So the shape is not a sender's business any more. It lives here, one
11
11
  * implementation, and every report calls it:
12
12
  *
13
- * trend(210, 207) → '210 / 207 ▲3'
14
- * trend(202, 207) → '202 / 207 ▼5'
13
+ * trend(210, 207) → '207 / 210 ▲3'
14
+ * trend(202, 207) → '207 / 202 ▼5'
15
15
  * trend(0, 0) → '0 / 0 ='
16
16
  * trend(37) → '37' nothing to compare to, so no mark
17
- * trend(4.4, 3.6, '%') → '4.4% / 3.6% ▲0.8'
17
+ * trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
18
18
  *
19
- * Both numbers are printed, now first and before first. The owner read
20
- * `51 ▲5` and asked what the 5 was — the new value or the old one. Neither: it
21
- * was the distance between two numbers, one of which the card never showed.
19
+ * Both numbers are printed, old first, new second left is what it was,
20
+ * right is what it became. The owner read `51 ▲5` first and asked what the 5
21
+ * was the new value or the old one. Neither: it was the distance between
22
+ * two numbers, one of which the card never showed. Once both were on the
23
+ * card the owner asked for THIS order specifically, so a reader can read the
24
+ * row left to right as a sentence: was, became, and by how much.
22
25
  *
23
26
  * The rule the owner asked for, in one line: where there is data to compare
24
27
  * against, the arrow is printed; where there is none, nothing is printed —
package/dist/trend.js CHANGED
@@ -10,15 +10,18 @@
10
10
  * So the shape is not a sender's business any more. It lives here, one
11
11
  * implementation, and every report calls it:
12
12
  *
13
- * trend(210, 207) → '210 / 207 ▲3'
14
- * trend(202, 207) → '202 / 207 ▼5'
13
+ * trend(210, 207) → '207 / 210 ▲3'
14
+ * trend(202, 207) → '207 / 202 ▼5'
15
15
  * trend(0, 0) → '0 / 0 ='
16
16
  * trend(37) → '37' nothing to compare to, so no mark
17
- * trend(4.4, 3.6, '%') → '4.4% / 3.6% ▲0.8'
17
+ * trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
18
18
  *
19
- * Both numbers are printed, now first and before first. The owner read
20
- * `51 ▲5` and asked what the 5 was — the new value or the old one. Neither: it
21
- * was the distance between two numbers, one of which the card never showed.
19
+ * Both numbers are printed, old first, new second left is what it was,
20
+ * right is what it became. The owner read `51 ▲5` first and asked what the 5
21
+ * was the new value or the old one. Neither: it was the distance between
22
+ * two numbers, one of which the card never showed. Once both were on the
23
+ * card the owner asked for THIS order specifically, so a reader can read the
24
+ * row left to right as a sentence: was, became, and by how much.
22
25
  *
23
26
  * The rule the owner asked for, in one line: where there is data to compare
24
27
  * against, the arrow is printed; where there is none, nothing is printed —
@@ -26,19 +29,23 @@
26
29
  */
27
30
  /** Integers stay integers; anything else keeps one decimal. */
28
31
  const fmt = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(1));
29
- /**
30
- * Two values that round to the same first decimal are equal: `4.42%` against
31
- * `4.44%` is not movement, it is noise, and `▲0.0` reads as a lie.
32
- */
33
- const same = (a, b) => Math.abs(a - b) < 0.05;
34
32
  export const trend = (now, was, unit = '') => {
35
33
  const head = `${fmt(now)}${unit}`;
36
34
  if (was === undefined || !Number.isFinite(was)) {
37
35
  return head;
38
36
  }
39
- const pair = `${head} / ${fmt(was)}${unit}`;
40
- if (same(now, was)) {
37
+ const pair = `${fmt(was)}${unit} / ${head}`;
38
+ // Equality is judged on the printed digits, not the raw numbers: `4.44` and
39
+ // `4.46` round to different labels (`4.4%` / `4.5%`), and printing `=` next
40
+ // to two different numbers reads as a lie no threshold on the raw values
41
+ // can prevent.
42
+ if (fmt(now) === fmt(was)) {
41
43
  return `${pair} =`;
42
44
  }
43
- return now > was ? `${pair} ▲${fmt(now - was)}` : `${pair} ▼${fmt(was - now)}`;
45
+ // The diff is computed from the two PRINTED numbers, not the raw ones: a
46
+ // diff of the raw values can round to `0.0` even when the printed pair
47
+ // reads `4.4% / 4.5%` — the arrow would show movement of nothing next to
48
+ // two numbers that are visibly different.
49
+ const diff = Number(fmt(now)) - Number(fmt(was));
50
+ return diff > 0 ? `${pair} ▲${fmt(Math.abs(diff))}` : `${pair} ▼${fmt(Math.abs(diff))}`;
44
51
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",