@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 +1 -1
- package/dist/cli.js +20 -6
- package/dist/events.d.ts +50 -17
- package/dist/render.js +141 -61
- package/dist/trend.d.ts +9 -6
- package/dist/trend.js +21 -14
- package/package.json +1 -1
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"
|
|
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
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
* as `#key` (with no project name: the card already sits
|
|
17
|
-
* project's forum — `targets()` never sends it to someone else's).
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
199
|
-
* items. Replaces `
|
|
200
|
-
*
|
|
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) —
|
|
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
|
|
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
|
-
/**
|
|
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 `
|
|
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
|
|
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
|
|
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('>'
|
|
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
|
-
/**
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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`).
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
|
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
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
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
|
|
535
|
-
|
|
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
|
|
552
|
-
//
|
|
553
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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) =>
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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) → '
|
|
14
|
-
* trend(202, 207) → '
|
|
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, '%') → '
|
|
17
|
+
* trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
|
|
18
18
|
*
|
|
19
|
-
* Both numbers are printed,
|
|
20
|
-
* `51 ▲5` and asked what the 5
|
|
21
|
-
* was the
|
|
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) → '
|
|
14
|
-
* trend(202, 207) → '
|
|
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, '%') → '
|
|
17
|
+
* trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
|
|
18
18
|
*
|
|
19
|
-
* Both numbers are printed,
|
|
20
|
-
* `51 ▲5` and asked what the 5
|
|
21
|
-
* was the
|
|
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 = `${
|
|
40
|
-
|
|
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
|
-
|
|
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