@crewhaus/tool-notify 0.7.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 +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message structure, and the escaping that keeps a value from becoming it.
|
|
3
|
+
*
|
|
4
|
+
* A notification is almost always assembled from data the harness did not
|
|
5
|
+
* write: a branch name, an error string, a customer's subject line. If that
|
|
6
|
+
* data reaches a chat platform unescaped it stops being a value and becomes
|
|
7
|
+
* syntax — `<!channel>` on Slack pages everyone in the room, `@everyone` on
|
|
8
|
+
* Discord does the same, and a stray ``` turns the rest of the message into
|
|
9
|
+
* a code block. That is markup injection, and it is the reason this file
|
|
10
|
+
* exists separately from the tools: escaping is a pure function that can be
|
|
11
|
+
* tested exhaustively, and every path that puts text on a platform goes
|
|
12
|
+
* through it.
|
|
13
|
+
*
|
|
14
|
+
* The blocks are deliberately few. Five kinds cover a notification — a
|
|
15
|
+
* heading, a paragraph, a list of name/value fields, a rule, a link — and
|
|
16
|
+
* each renders to something every platform actually supports. A richer model
|
|
17
|
+
* would render well on one platform and badly on the others, which is worse
|
|
18
|
+
* than a plain one that renders the same everywhere.
|
|
19
|
+
*/
|
|
20
|
+
export type Platform = "slack" | "discord" | "teams" | "webhook";
|
|
21
|
+
export type Block = {
|
|
22
|
+
readonly kind: "heading";
|
|
23
|
+
readonly text: string;
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: "paragraph";
|
|
26
|
+
readonly text: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "fields";
|
|
29
|
+
readonly fields: ReadonlyArray<{
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly value: string;
|
|
32
|
+
}>;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: "divider";
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: "link";
|
|
37
|
+
readonly url: string;
|
|
38
|
+
readonly text?: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Escape a VALUE for a platform, so it can only ever be read as text.
|
|
42
|
+
*
|
|
43
|
+
* - **slack**: `&`, `<` and `>` are the only three characters Slack treats
|
|
44
|
+
* as markup structure, and escaping them is exactly what Slack's own docs
|
|
45
|
+
* prescribe. It kills `<!channel>`, `<!here>`, `<@U123>` and the
|
|
46
|
+
* `<url|label>` link form in one move. Slack's `*bold*`/`_italic_` marks
|
|
47
|
+
* are cosmetic — they cannot change who is notified or where a link goes
|
|
48
|
+
* — so they are left alone rather than backslash-littering every message.
|
|
49
|
+
* - **discord**: backslash-escape the characters that open markdown
|
|
50
|
+
* constructs, so a value cannot close a code fence or start a spoiler.
|
|
51
|
+
* Mentions are NOT handled here: Discord has no in-content escape for
|
|
52
|
+
* them, and the correct fix is the `allowed_mentions` field on the
|
|
53
|
+
* request, which `buildChatPayload` always sets to "parse nothing".
|
|
54
|
+
* - **teams**: HTML-escape first, because Teams renders a card's text as
|
|
55
|
+
* HTML, then backslash-escape the markdown marks it also honours.
|
|
56
|
+
* - **webhook**: identity. A generic webhook receives JSON; JSON encoding
|
|
57
|
+
* is the escaping, and mangling the value on the way would corrupt a
|
|
58
|
+
* payload nobody asked to be rendered.
|
|
59
|
+
*/
|
|
60
|
+
export declare function escapeFor(platform: Platform, value: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Is a URL safe to put behind a link?
|
|
63
|
+
*
|
|
64
|
+
* `javascript:`, `data:` and `vbscript:` in a chat message are a phishing
|
|
65
|
+
* payload aimed at whoever clicks. Only http and https get rendered as
|
|
66
|
+
* links; anything else is refused rather than silently downgraded, because a
|
|
67
|
+
* caller who asked for a link and got plain text would not notice.
|
|
68
|
+
*/
|
|
69
|
+
export declare function linkSchemeAllowed(url: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Render a text body plus blocks into one string for a platform.
|
|
72
|
+
*
|
|
73
|
+
* Blocks with an unusable link are dropped and reported rather than
|
|
74
|
+
* rendered, so a `javascript:` URL never reaches a reader.
|
|
75
|
+
*/
|
|
76
|
+
export declare function renderBlocks(platform: Platform, text: string | undefined, blocks: readonly Block[]): {
|
|
77
|
+
readonly rendered: string;
|
|
78
|
+
readonly dropped: readonly string[];
|
|
79
|
+
};
|
|
80
|
+
/** Platform byte/character limits, so a message is cut here rather than there. */
|
|
81
|
+
export declare const PLATFORM_TEXT_LIMIT: Readonly<Record<Platform, number>>;
|
|
82
|
+
/**
|
|
83
|
+
* Cut `text` to a platform's limit, marking the cut. Counting is in UTF-16
|
|
84
|
+
* code units, which is what every one of these platforms documents its limit
|
|
85
|
+
* in, and the cut never splits a surrogate pair.
|
|
86
|
+
*/
|
|
87
|
+
export declare function clampToPlatform(platform: Platform, text: string): {
|
|
88
|
+
readonly text: string;
|
|
89
|
+
readonly truncated: boolean;
|
|
90
|
+
};
|
|
91
|
+
export type ChatPayload = {
|
|
92
|
+
readonly body: string;
|
|
93
|
+
readonly contentType: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The JSON body a platform's incoming-webhook or API endpoint expects.
|
|
97
|
+
*
|
|
98
|
+
* Slack and Discord both accept a thread reference, and both are told not to
|
|
99
|
+
* resolve mentions: Slack via `link_names: false` plus the escaping above,
|
|
100
|
+
* Discord via `allowed_mentions: { parse: [] }`, which is the only reliable
|
|
101
|
+
* way to stop `@everyone` in a message body from paging a server.
|
|
102
|
+
*/
|
|
103
|
+
export declare function buildChatPayload(input: {
|
|
104
|
+
readonly platform: Platform;
|
|
105
|
+
readonly text: string;
|
|
106
|
+
readonly threadId?: string | undefined;
|
|
107
|
+
readonly channel?: string | undefined;
|
|
108
|
+
readonly username?: string | undefined;
|
|
109
|
+
}): ChatPayload;
|
|
110
|
+
/** Platforms whose posted messages can be edited or deleted by id. */
|
|
111
|
+
export declare const EDITABLE_PLATFORMS: ReadonlySet<Platform>;
|
|
112
|
+
/** Platforms that carry a thread identifier on a post. */
|
|
113
|
+
export declare const THREADED_PLATFORMS: ReadonlySet<Platform>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message structure, and the escaping that keeps a value from becoming it.
|
|
3
|
+
*
|
|
4
|
+
* A notification is almost always assembled from data the harness did not
|
|
5
|
+
* write: a branch name, an error string, a customer's subject line. If that
|
|
6
|
+
* data reaches a chat platform unescaped it stops being a value and becomes
|
|
7
|
+
* syntax — `<!channel>` on Slack pages everyone in the room, `@everyone` on
|
|
8
|
+
* Discord does the same, and a stray ``` turns the rest of the message into
|
|
9
|
+
* a code block. That is markup injection, and it is the reason this file
|
|
10
|
+
* exists separately from the tools: escaping is a pure function that can be
|
|
11
|
+
* tested exhaustively, and every path that puts text on a platform goes
|
|
12
|
+
* through it.
|
|
13
|
+
*
|
|
14
|
+
* The blocks are deliberately few. Five kinds cover a notification — a
|
|
15
|
+
* heading, a paragraph, a list of name/value fields, a rule, a link — and
|
|
16
|
+
* each renders to something every platform actually supports. A richer model
|
|
17
|
+
* would render well on one platform and badly on the others, which is worse
|
|
18
|
+
* than a plain one that renders the same everywhere.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Escape a VALUE for a platform, so it can only ever be read as text.
|
|
22
|
+
*
|
|
23
|
+
* - **slack**: `&`, `<` and `>` are the only three characters Slack treats
|
|
24
|
+
* as markup structure, and escaping them is exactly what Slack's own docs
|
|
25
|
+
* prescribe. It kills `<!channel>`, `<!here>`, `<@U123>` and the
|
|
26
|
+
* `<url|label>` link form in one move. Slack's `*bold*`/`_italic_` marks
|
|
27
|
+
* are cosmetic — they cannot change who is notified or where a link goes
|
|
28
|
+
* — so they are left alone rather than backslash-littering every message.
|
|
29
|
+
* - **discord**: backslash-escape the characters that open markdown
|
|
30
|
+
* constructs, so a value cannot close a code fence or start a spoiler.
|
|
31
|
+
* Mentions are NOT handled here: Discord has no in-content escape for
|
|
32
|
+
* them, and the correct fix is the `allowed_mentions` field on the
|
|
33
|
+
* request, which `buildChatPayload` always sets to "parse nothing".
|
|
34
|
+
* - **teams**: HTML-escape first, because Teams renders a card's text as
|
|
35
|
+
* HTML, then backslash-escape the markdown marks it also honours.
|
|
36
|
+
* - **webhook**: identity. A generic webhook receives JSON; JSON encoding
|
|
37
|
+
* is the escaping, and mangling the value on the way would corrupt a
|
|
38
|
+
* payload nobody asked to be rendered.
|
|
39
|
+
*/
|
|
40
|
+
export function escapeFor(platform, value) {
|
|
41
|
+
switch (platform) {
|
|
42
|
+
case "slack":
|
|
43
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
44
|
+
case "discord":
|
|
45
|
+
// Only the marks that introduce STRUCTURE: code fences, emphasis,
|
|
46
|
+
// spoilers, block quotes and links. `#`, `-` and `1.` make a heading or
|
|
47
|
+
// a list at the start of a line and cannot change what the message
|
|
48
|
+
// does, so they are left alone rather than littering every hyphen in
|
|
49
|
+
// every branch name with a backslash.
|
|
50
|
+
return value.replace(/([\\`*_~|>[\]()])/g, "\\$1");
|
|
51
|
+
case "teams":
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, "&")
|
|
54
|
+
.replace(/</g, "<")
|
|
55
|
+
.replace(/>/g, ">")
|
|
56
|
+
.replace(/([\\`*_~\[\]])/g, "\\$1");
|
|
57
|
+
case "webhook":
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Is a URL safe to put behind a link?
|
|
63
|
+
*
|
|
64
|
+
* `javascript:`, `data:` and `vbscript:` in a chat message are a phishing
|
|
65
|
+
* payload aimed at whoever clicks. Only http and https get rendered as
|
|
66
|
+
* links; anything else is refused rather than silently downgraded, because a
|
|
67
|
+
* caller who asked for a link and got plain text would not notice.
|
|
68
|
+
*/
|
|
69
|
+
export function linkSchemeAllowed(url) {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = new URL(url);
|
|
72
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Render one block for a platform. Values are escaped; structure is not. */
|
|
79
|
+
function renderBlock(platform, block) {
|
|
80
|
+
switch (block.kind) {
|
|
81
|
+
case "heading": {
|
|
82
|
+
const text = escapeFor(platform, block.text);
|
|
83
|
+
if (platform === "slack")
|
|
84
|
+
return `*${text}*`;
|
|
85
|
+
if (platform === "webhook")
|
|
86
|
+
return block.text;
|
|
87
|
+
return `**${text}**`;
|
|
88
|
+
}
|
|
89
|
+
case "paragraph":
|
|
90
|
+
return escapeFor(platform, block.text);
|
|
91
|
+
case "fields": {
|
|
92
|
+
const lines = block.fields.map((f) => {
|
|
93
|
+
const name = escapeFor(platform, f.name);
|
|
94
|
+
const value = escapeFor(platform, f.value);
|
|
95
|
+
if (platform === "slack")
|
|
96
|
+
return `*${name}:* ${value}`;
|
|
97
|
+
if (platform === "webhook")
|
|
98
|
+
return `${f.name}: ${f.value}`;
|
|
99
|
+
return `**${name}:** ${value}`;
|
|
100
|
+
});
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
case "divider":
|
|
104
|
+
return platform === "slack" ? "───" : "---";
|
|
105
|
+
case "link": {
|
|
106
|
+
const label = block.text ?? block.url;
|
|
107
|
+
if (platform === "slack") {
|
|
108
|
+
// The URL goes inside Slack's own link syntax unescaped — escaping it
|
|
109
|
+
// would break the link — but the LABEL is escaped, because a label
|
|
110
|
+
// containing `|` or `>` would otherwise close the construct early and
|
|
111
|
+
// let the rest of the label become markup.
|
|
112
|
+
return `<${block.url}|${escapeFor("slack", label)}>`;
|
|
113
|
+
}
|
|
114
|
+
if (platform === "webhook")
|
|
115
|
+
return `${label}: ${block.url}`;
|
|
116
|
+
return `[${escapeFor(platform, label)}](${block.url})`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Render a text body plus blocks into one string for a platform.
|
|
122
|
+
*
|
|
123
|
+
* Blocks with an unusable link are dropped and reported rather than
|
|
124
|
+
* rendered, so a `javascript:` URL never reaches a reader.
|
|
125
|
+
*/
|
|
126
|
+
export function renderBlocks(platform, text, blocks) {
|
|
127
|
+
const dropped = [];
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (text !== undefined && text !== "")
|
|
130
|
+
parts.push(escapeFor(platform, text));
|
|
131
|
+
for (const block of blocks) {
|
|
132
|
+
if (block.kind === "link" && !linkSchemeAllowed(block.url)) {
|
|
133
|
+
dropped.push("a link block whose URL was not http(s)");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
parts.push(renderBlock(platform, block));
|
|
137
|
+
}
|
|
138
|
+
return { rendered: parts.join("\n\n"), dropped };
|
|
139
|
+
}
|
|
140
|
+
/** Platform byte/character limits, so a message is cut here rather than there. */
|
|
141
|
+
export const PLATFORM_TEXT_LIMIT = {
|
|
142
|
+
slack: 40_000,
|
|
143
|
+
discord: 2_000,
|
|
144
|
+
teams: 28_000,
|
|
145
|
+
webhook: 100_000,
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Cut `text` to a platform's limit, marking the cut. Counting is in UTF-16
|
|
149
|
+
* code units, which is what every one of these platforms documents its limit
|
|
150
|
+
* in, and the cut never splits a surrogate pair.
|
|
151
|
+
*/
|
|
152
|
+
export function clampToPlatform(platform, text) {
|
|
153
|
+
const limit = PLATFORM_TEXT_LIMIT[platform];
|
|
154
|
+
if (text.length <= limit)
|
|
155
|
+
return { text, truncated: false };
|
|
156
|
+
const marker = "\n… (truncated)";
|
|
157
|
+
let cut = limit - marker.length;
|
|
158
|
+
const code = text.charCodeAt(cut - 1);
|
|
159
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
160
|
+
cut -= 1; // do not split a surrogate pair
|
|
161
|
+
return { text: `${text.slice(0, cut)}${marker}`, truncated: true };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The JSON body a platform's incoming-webhook or API endpoint expects.
|
|
165
|
+
*
|
|
166
|
+
* Slack and Discord both accept a thread reference, and both are told not to
|
|
167
|
+
* resolve mentions: Slack via `link_names: false` plus the escaping above,
|
|
168
|
+
* Discord via `allowed_mentions: { parse: [] }`, which is the only reliable
|
|
169
|
+
* way to stop `@everyone` in a message body from paging a server.
|
|
170
|
+
*/
|
|
171
|
+
export function buildChatPayload(input) {
|
|
172
|
+
const { platform, text } = input;
|
|
173
|
+
if (platform === "slack") {
|
|
174
|
+
return {
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
text,
|
|
177
|
+
link_names: false,
|
|
178
|
+
...(input.channel !== undefined ? { channel: input.channel } : {}),
|
|
179
|
+
...(input.threadId !== undefined ? { thread_ts: input.threadId } : {}),
|
|
180
|
+
...(input.username !== undefined ? { username: input.username } : {}),
|
|
181
|
+
}),
|
|
182
|
+
contentType: "application/json",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (platform === "discord") {
|
|
186
|
+
return {
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
content: text,
|
|
189
|
+
allowed_mentions: { parse: [] },
|
|
190
|
+
...(input.username !== undefined ? { username: input.username } : {}),
|
|
191
|
+
}),
|
|
192
|
+
contentType: "application/json",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (platform === "teams") {
|
|
196
|
+
return {
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
"@type": "MessageCard",
|
|
199
|
+
"@context": "https://schema.org/extensions",
|
|
200
|
+
text,
|
|
201
|
+
}),
|
|
202
|
+
contentType: "application/json",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
text,
|
|
208
|
+
...(input.channel !== undefined ? { channel: input.channel } : {}),
|
|
209
|
+
...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
|
|
210
|
+
}),
|
|
211
|
+
contentType: "application/json",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Platforms whose posted messages can be edited or deleted by id. */
|
|
215
|
+
export const EDITABLE_PLATFORMS = new Set(["slack", "discord"]);
|
|
216
|
+
/** Platforms that carry a thread identifier on a post. */
|
|
217
|
+
export const THREADED_PLATFORMS = new Set(["slack", "discord"]);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folding many events into one message.
|
|
3
|
+
*
|
|
4
|
+
* This is the function that stops a harness sending forty notifications when
|
|
5
|
+
* one would do. A watcher loop that fires per event will, on a bad morning,
|
|
6
|
+
* page a channel forty times with the same failure; a digest turns that into
|
|
7
|
+
* "38× connection refused (host-a, host-b), 2× timeout" and the reader
|
|
8
|
+
* learns more from it.
|
|
9
|
+
*
|
|
10
|
+
* Pure, and deliberately boring about order: groups are ranked by count and
|
|
11
|
+
* ties are broken by key, with a plain codepoint comparison rather than
|
|
12
|
+
* `localeCompare`, so the same events always fold to the same bytes.
|
|
13
|
+
*/
|
|
14
|
+
import type { Block } from "./blocks";
|
|
15
|
+
export type DigestEvent = {
|
|
16
|
+
/** What makes two events "the same". Events sharing a key are counted. */
|
|
17
|
+
readonly key: string;
|
|
18
|
+
/** One line about this occurrence. The first one seen is the exemplar. */
|
|
19
|
+
readonly summary?: string;
|
|
20
|
+
/** Free-form labels; distinct values are listed under the group. */
|
|
21
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
22
|
+
/** Severity, used only for ordering when `sort` is "severity". */
|
|
23
|
+
readonly severity?: "info" | "warning" | "error" | "critical";
|
|
24
|
+
};
|
|
25
|
+
export type DigestGroup = {
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly count: number;
|
|
28
|
+
readonly summary?: string;
|
|
29
|
+
readonly severity?: DigestEvent["severity"];
|
|
30
|
+
/** label name → the distinct values seen, sorted and capped. */
|
|
31
|
+
readonly labels: Readonly<Record<string, readonly string[]>>;
|
|
32
|
+
};
|
|
33
|
+
export type Digest = {
|
|
34
|
+
readonly total: number;
|
|
35
|
+
readonly distinct: number;
|
|
36
|
+
readonly groups: readonly DigestGroup[];
|
|
37
|
+
/** Groups that did not fit under `maxGroups`, and how many events they held. */
|
|
38
|
+
readonly omittedGroups: number;
|
|
39
|
+
readonly omittedEvents: number;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Fold events into groups.
|
|
43
|
+
*
|
|
44
|
+
* `maxGroups` caps what is rendered, not what is counted: the totals always
|
|
45
|
+
* describe every event handed in, and the omitted tail is reported as a
|
|
46
|
+
* number rather than dropped silently. A digest that quietly loses events is
|
|
47
|
+
* the failure mode that makes people distrust digests.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildDigest(events: readonly DigestEvent[], options?: {
|
|
50
|
+
readonly maxGroups?: number;
|
|
51
|
+
readonly sort?: "count" | "severity" | "key";
|
|
52
|
+
}): Digest;
|
|
53
|
+
/** One line per group: `12× build failed — host: a, b`. */
|
|
54
|
+
export declare function digestToText(digest: Digest, title?: string): string;
|
|
55
|
+
/** The same digest as blocks, for a platform that renders them. */
|
|
56
|
+
export declare function digestToBlocks(digest: Digest, title?: string): Block[];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const SEVERITY_RANK = {
|
|
2
|
+
critical: 0,
|
|
3
|
+
error: 1,
|
|
4
|
+
warning: 2,
|
|
5
|
+
info: 3,
|
|
6
|
+
};
|
|
7
|
+
function byString(a, b) {
|
|
8
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
9
|
+
}
|
|
10
|
+
/** Cap on distinct values listed per label, so one group cannot flood the digest. */
|
|
11
|
+
const MAX_LABEL_VALUES = 8;
|
|
12
|
+
/**
|
|
13
|
+
* Fold events into groups.
|
|
14
|
+
*
|
|
15
|
+
* `maxGroups` caps what is rendered, not what is counted: the totals always
|
|
16
|
+
* describe every event handed in, and the omitted tail is reported as a
|
|
17
|
+
* number rather than dropped silently. A digest that quietly loses events is
|
|
18
|
+
* the failure mode that makes people distrust digests.
|
|
19
|
+
*/
|
|
20
|
+
export function buildDigest(events, options = {}) {
|
|
21
|
+
const maxGroups = Math.max(1, options.maxGroups ?? 10);
|
|
22
|
+
const sort = options.sort ?? "count";
|
|
23
|
+
const groups = new Map();
|
|
24
|
+
events.forEach((event, index) => {
|
|
25
|
+
let group = groups.get(event.key);
|
|
26
|
+
if (group === undefined) {
|
|
27
|
+
group = {
|
|
28
|
+
key: event.key,
|
|
29
|
+
count: 0,
|
|
30
|
+
labels: new Map(),
|
|
31
|
+
firstIndex: index,
|
|
32
|
+
...(event.summary !== undefined ? { summary: event.summary } : {}),
|
|
33
|
+
...(event.severity !== undefined ? { severity: event.severity } : {}),
|
|
34
|
+
};
|
|
35
|
+
groups.set(event.key, group);
|
|
36
|
+
}
|
|
37
|
+
group.count += 1;
|
|
38
|
+
// The most severe occurrence wins: a group that was warning-then-critical
|
|
39
|
+
// is a critical group, and rendering it as a warning would understate it.
|
|
40
|
+
if (event.severity !== undefined) {
|
|
41
|
+
const current = group.severity;
|
|
42
|
+
if (current === undefined ||
|
|
43
|
+
(SEVERITY_RANK[event.severity] ?? 9) < (SEVERITY_RANK[current] ?? 9)) {
|
|
44
|
+
group.severity = event.severity;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const [name, value] of Object.entries(event.labels ?? {})) {
|
|
48
|
+
let values = group.labels.get(name);
|
|
49
|
+
if (values === undefined) {
|
|
50
|
+
values = new Set();
|
|
51
|
+
group.labels.set(name, values);
|
|
52
|
+
}
|
|
53
|
+
values.add(value);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const ordered = [...groups.values()].sort((a, b) => {
|
|
57
|
+
if (sort === "key")
|
|
58
|
+
return byString(a.key, b.key);
|
|
59
|
+
if (sort === "severity") {
|
|
60
|
+
const sa = SEVERITY_RANK[a.severity ?? "info"] ?? 9;
|
|
61
|
+
const sb = SEVERITY_RANK[b.severity ?? "info"] ?? 9;
|
|
62
|
+
if (sa !== sb)
|
|
63
|
+
return sa - sb;
|
|
64
|
+
}
|
|
65
|
+
if (b.count !== a.count)
|
|
66
|
+
return b.count - a.count;
|
|
67
|
+
return byString(a.key, b.key);
|
|
68
|
+
});
|
|
69
|
+
const kept = ordered.slice(0, maxGroups);
|
|
70
|
+
const omitted = ordered.slice(maxGroups);
|
|
71
|
+
return {
|
|
72
|
+
total: events.length,
|
|
73
|
+
distinct: ordered.length,
|
|
74
|
+
omittedGroups: omitted.length,
|
|
75
|
+
omittedEvents: omitted.reduce((sum, g) => sum + g.count, 0),
|
|
76
|
+
groups: kept.map((group) => {
|
|
77
|
+
const labels = {};
|
|
78
|
+
for (const name of [...group.labels.keys()].sort(byString)) {
|
|
79
|
+
const values = [...(group.labels.get(name) ?? new Set())].sort(byString);
|
|
80
|
+
labels[name] = values.slice(0, MAX_LABEL_VALUES);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
key: group.key,
|
|
84
|
+
count: group.count,
|
|
85
|
+
labels,
|
|
86
|
+
...(group.summary !== undefined ? { summary: group.summary } : {}),
|
|
87
|
+
...(group.severity !== undefined ? { severity: group.severity } : {}),
|
|
88
|
+
};
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** One line per group: `12× build failed — host: a, b`. */
|
|
93
|
+
export function digestToText(digest, title) {
|
|
94
|
+
const lines = [];
|
|
95
|
+
if (title !== undefined && title !== "")
|
|
96
|
+
lines.push(title);
|
|
97
|
+
lines.push(`${digest.total} events in ${digest.distinct} groups`);
|
|
98
|
+
for (const group of digest.groups) {
|
|
99
|
+
const labelText = Object.entries(group.labels)
|
|
100
|
+
.map(([name, values]) => `${name}: ${values.join(", ")}`)
|
|
101
|
+
.join("; ");
|
|
102
|
+
const head = `${group.count}× ${group.summary ?? group.key}`;
|
|
103
|
+
const severity = group.severity !== undefined ? ` [${group.severity}]` : "";
|
|
104
|
+
lines.push(`${head}${severity}${labelText === "" ? "" : ` — ${labelText}`}`);
|
|
105
|
+
}
|
|
106
|
+
if (digest.omittedGroups > 0) {
|
|
107
|
+
lines.push(`…and ${digest.omittedGroups} more groups covering ${digest.omittedEvents} events, not shown`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
/** The same digest as blocks, for a platform that renders them. */
|
|
112
|
+
export function digestToBlocks(digest, title) {
|
|
113
|
+
const blocks = [];
|
|
114
|
+
if (title !== undefined && title !== "")
|
|
115
|
+
blocks.push({ kind: "heading", text: title });
|
|
116
|
+
blocks.push({
|
|
117
|
+
kind: "paragraph",
|
|
118
|
+
text: `${digest.total} events in ${digest.distinct} groups`,
|
|
119
|
+
});
|
|
120
|
+
blocks.push({ kind: "divider" });
|
|
121
|
+
for (const group of digest.groups) {
|
|
122
|
+
const fields = [
|
|
123
|
+
{ name: "count", value: String(group.count) },
|
|
124
|
+
];
|
|
125
|
+
if (group.severity !== undefined)
|
|
126
|
+
fields.push({ name: "severity", value: group.severity });
|
|
127
|
+
for (const [name, values] of Object.entries(group.labels)) {
|
|
128
|
+
fields.push({ name, value: values.join(", ") });
|
|
129
|
+
}
|
|
130
|
+
blocks.push({ kind: "paragraph", text: group.summary ?? group.key });
|
|
131
|
+
blocks.push({ kind: "fields", fields });
|
|
132
|
+
}
|
|
133
|
+
if (digest.omittedGroups > 0) {
|
|
134
|
+
blocks.push({
|
|
135
|
+
kind: "paragraph",
|
|
136
|
+
text: `…and ${digest.omittedGroups} more groups covering ${digest.omittedEvents} events, not shown`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return blocks;
|
|
140
|
+
}
|