@expo/code-review-cli 0.2.3 β 0.4.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 +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- package/build/core/coordinator.js +5 -5
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +25 -25
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/config.jsonc +10 -0
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +58 -23
package/build/core/render.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { fingerprintFinding, SEVERITIES, SEVERITY_RANK } from
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK } from "./schema.js";
|
|
3
3
|
/**
|
|
4
4
|
* Build the file β right-side-line-numbers index from changed files' patch text,
|
|
5
5
|
* by walking each unified-diff hunk (`@@ -a,b +c,d @@`) and collecting the new-tree
|
|
@@ -13,18 +13,18 @@ export function buildDiffLineIndex(files) {
|
|
|
13
13
|
const lines = new Set();
|
|
14
14
|
let right = 0;
|
|
15
15
|
let inHunk = false;
|
|
16
|
-
for (const raw of file.patch.split(
|
|
16
|
+
for (const raw of file.patch.split("\n")) {
|
|
17
17
|
const hunk = hunkRe.exec(raw);
|
|
18
18
|
if (hunk) {
|
|
19
19
|
right = parseInt(hunk[1], 10);
|
|
20
20
|
inHunk = true;
|
|
21
21
|
continue;
|
|
22
22
|
}
|
|
23
|
-
if (!inHunk || raw.startsWith(
|
|
23
|
+
if (!inHunk || raw.startsWith("+++") || raw.startsWith("---") || raw.startsWith("\\")) {
|
|
24
24
|
continue;
|
|
25
25
|
}
|
|
26
26
|
const marker = raw[0];
|
|
27
|
-
if (marker ===
|
|
27
|
+
if (marker === "+" || marker === " ") {
|
|
28
28
|
lines.add(right);
|
|
29
29
|
right++;
|
|
30
30
|
}
|
|
@@ -37,16 +37,16 @@ export function buildDiffLineIndex(files) {
|
|
|
37
37
|
return index;
|
|
38
38
|
}
|
|
39
39
|
const DECISION_LABEL = {
|
|
40
|
-
approve:
|
|
41
|
-
approve_with_comments:
|
|
42
|
-
request_changes:
|
|
40
|
+
approve: "Approve",
|
|
41
|
+
approve_with_comments: "Approve with comments",
|
|
42
|
+
request_changes: "Request changes",
|
|
43
43
|
};
|
|
44
44
|
export function decisionLabel(decision) {
|
|
45
45
|
return DECISION_LABEL[decision];
|
|
46
46
|
}
|
|
47
47
|
/** Rubric exit code: 0 for approve / approve-with-comments, 1 for request-changes. */
|
|
48
48
|
export function decisionExitCode(decision) {
|
|
49
|
-
return decision ===
|
|
49
|
+
return decision === "request_changes" ? 1 : 0;
|
|
50
50
|
}
|
|
51
51
|
export function sortFindings(findings) {
|
|
52
52
|
return [...findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
@@ -85,13 +85,13 @@ function location(finding, link) {
|
|
|
85
85
|
// in-diff as long as the file appears in the diff.
|
|
86
86
|
const inDiff = fileLines != null && (finding.line == null || fileLines.has(finding.line));
|
|
87
87
|
if (inDiff) {
|
|
88
|
-
const fileHash = createHash(
|
|
88
|
+
const fileHash = createHash("sha256").update(finding.file).digest("hex");
|
|
89
89
|
const anchor = finding.line != null ? `diff-${fileHash}R${finding.line}` : `diff-${fileHash}`;
|
|
90
90
|
const url = `https://github.com/${link.repo}/pull/${link.prNumber}/files#${anchor}`;
|
|
91
91
|
return `[\`${text}\`](${url})`;
|
|
92
92
|
}
|
|
93
93
|
if (link.baseSha) {
|
|
94
|
-
const lineAnchor = finding.line != null ? `#L${finding.line}` :
|
|
94
|
+
const lineAnchor = finding.line != null ? `#L${finding.line}` : "";
|
|
95
95
|
const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${finding.file}${lineAnchor}`;
|
|
96
96
|
return `[\`${text}\`](${url})`;
|
|
97
97
|
}
|
|
@@ -103,53 +103,63 @@ function location(finding, link) {
|
|
|
103
103
|
* collapsed "Dismissed" section instead of the main list.
|
|
104
104
|
*/
|
|
105
105
|
export function renderMarkdown(review, tag, dismissed = [], link) {
|
|
106
|
-
const dismissedByFp = new Map(dismissed.map(record => [record.fp, record]));
|
|
107
|
-
const withFp = review.findings.map(finding => ({ finding, fp: fingerprintFinding(finding) }));
|
|
106
|
+
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
107
|
+
const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
|
|
108
108
|
const kept = withFp.filter(({ fp }) => !dismissedByFp.has(fp));
|
|
109
109
|
const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
|
|
110
|
-
const lines = [commentMarker(tag),
|
|
111
|
-
lines.push(`**Decision:** ${decisionLabel(review.decision)}`,
|
|
110
|
+
const lines = [commentMarker(tag), "## π€ AI code review", ""];
|
|
111
|
+
lines.push(`**Decision:** ${decisionLabel(review.decision)}`, "", review.summary, "");
|
|
112
112
|
if (review.incomplete.length > 0) {
|
|
113
|
-
lines.push(
|
|
113
|
+
lines.push("> β±οΈ **Coverage note:** coverage is partial β some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
|
|
114
114
|
}
|
|
115
115
|
if (kept.length === 0) {
|
|
116
|
-
lines.push(
|
|
116
|
+
lines.push("No findings.", "");
|
|
117
117
|
}
|
|
118
118
|
else {
|
|
119
|
-
|
|
120
|
-
for (const severity of SEVERITIES) {
|
|
121
|
-
const group = groups[severity];
|
|
122
|
-
if (group.length === 0) {
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
lines.push(`### ${severityHeading(severity)} (${group.length})`, '');
|
|
126
|
-
for (const finding of group) {
|
|
127
|
-
lines.push(...renderFindingLines(finding, link));
|
|
128
|
-
}
|
|
129
|
-
lines.push('');
|
|
130
|
-
}
|
|
119
|
+
lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link));
|
|
131
120
|
}
|
|
132
121
|
if (dropped.length > 0) {
|
|
133
|
-
lines.push(
|
|
122
|
+
lines.push("<details>", `<summary>π« Dismissed on this PR (${dropped.length})</summary>`, "");
|
|
134
123
|
for (const { finding, fp } of dropped) {
|
|
135
124
|
const record = dismissedByFp.get(fp);
|
|
136
|
-
const who = record.by ? ` by @${record.by}` :
|
|
137
|
-
const why = record.reason ? ` β ${record.reason}` :
|
|
125
|
+
const who = record.by ? ` by @${record.by}` : "";
|
|
126
|
+
const why = record.reason ? ` β ${record.reason}` : "";
|
|
138
127
|
lines.push(`- **${finding.title}** β ${location(finding, link)} \`id:${fp}\`${who}${why}`);
|
|
139
128
|
}
|
|
140
|
-
lines.push(
|
|
129
|
+
lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
|
|
141
130
|
}
|
|
142
|
-
lines.push(
|
|
131
|
+
lines.push("---", "_This review is advisory β it never blocks a merge and never auto-approves._");
|
|
143
132
|
// Embedded, machine-readable state: fingerprints (back-compat) + the full review
|
|
144
133
|
// and dismissals, so `/dismiss` can re-render this comment without re-running.
|
|
145
134
|
const fingerprints = review.findings.map(fingerprintFinding);
|
|
146
|
-
lines.push(
|
|
135
|
+
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
147
136
|
lines.push(`<!-- ${tag}:state=${encodeState({ review, dismissed })} -->`);
|
|
148
|
-
return lines.join(
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Render sorted, severity-grouped findings. Shared by the single-comment and
|
|
141
|
+
* aggregate renderers. `idFor` supplies each finding's id (default:
|
|
142
|
+
* fingerprintFinding); the aggregate renderer passes a scope-namespaced id.
|
|
143
|
+
*/
|
|
144
|
+
function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
|
|
145
|
+
const out = [];
|
|
146
|
+
const groups = groupBySeverity(sortFindings(findings));
|
|
147
|
+
for (const severity of SEVERITIES) {
|
|
148
|
+
const group = groups[severity];
|
|
149
|
+
if (group.length === 0) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
out.push(`### ${severityHeading(severity)} (${group.length})`, "");
|
|
153
|
+
for (const finding of group) {
|
|
154
|
+
out.push(...renderFindingLines(finding, link, idFor(finding)));
|
|
155
|
+
}
|
|
156
|
+
out.push("");
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
149
159
|
}
|
|
150
|
-
function renderFindingLines(finding, link) {
|
|
160
|
+
function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
|
|
151
161
|
const out = [
|
|
152
|
-
`- **${finding.title}** β ${location(finding, link)} _(${finding.category})_ Β· \`id:${
|
|
162
|
+
`- **${finding.title}** β ${location(finding, link)} _(${finding.category})_ Β· \`id:${id}\``,
|
|
153
163
|
` ${finding.rationale}`,
|
|
154
164
|
];
|
|
155
165
|
if (finding.suggestion) {
|
|
@@ -160,7 +170,7 @@ function renderFindingLines(finding, link) {
|
|
|
160
170
|
/** Parse the fingerprints embedded in a previously-posted comment body. */
|
|
161
171
|
export function parseEmbeddedFingerprints(body, tag) {
|
|
162
172
|
// Escape the (config-controlled) tag so regex metacharacters can't break the match.
|
|
163
|
-
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g,
|
|
173
|
+
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
164
174
|
const match = body.match(new RegExp(`<!-- ${escapedTag}:fingerprints=(\\[.*?\\]) -->`));
|
|
165
175
|
if (!match) {
|
|
166
176
|
return [];
|
|
@@ -173,18 +183,162 @@ export function parseEmbeddedFingerprints(body, tag) {
|
|
|
173
183
|
return [];
|
|
174
184
|
}
|
|
175
185
|
}
|
|
186
|
+
const DECISION_RANK = {
|
|
187
|
+
approve: 0,
|
|
188
|
+
approve_with_comments: 1,
|
|
189
|
+
request_changes: 2,
|
|
190
|
+
};
|
|
191
|
+
/** Worst (most severe) decision across scopes. */
|
|
192
|
+
export function worstDecision(decisions) {
|
|
193
|
+
return decisions.reduce((worst, decision) => (DECISION_RANK[decision] > DECISION_RANK[worst] ? decision : worst), "approve");
|
|
194
|
+
}
|
|
195
|
+
/** GitHub's comment body limit is ~65k chars; keep a margin. */
|
|
196
|
+
const MAX_COMMENT_CHARS = 60_000;
|
|
197
|
+
/**
|
|
198
|
+
* One aggregated comment under the single existing marker: a scope summary table,
|
|
199
|
+
* an optional coverage block, one <details> per scope (findings rendered with
|
|
200
|
+
* scope-namespaced ids), and the dismissed section. The embedded v1 `review` field
|
|
201
|
+
* is a synthesized merge so v1 state consumers still see a valid shape; `scopes`
|
|
202
|
+
* carries the real per-scope data. Oversized bodies trim each scope's findings to
|
|
203
|
+
* the most severe N (halving until it fits, floor 3) with a per-scope note.
|
|
204
|
+
*/
|
|
205
|
+
export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
|
|
206
|
+
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
207
|
+
const idOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
|
|
208
|
+
// Split each scope's findings into kept/dropped once (dismissal is limit-independent).
|
|
209
|
+
const perScope = results.map((result) => {
|
|
210
|
+
const withId = result.review.findings.map((finding) => ({
|
|
211
|
+
finding,
|
|
212
|
+
id: idOf(result, finding),
|
|
213
|
+
}));
|
|
214
|
+
return {
|
|
215
|
+
result,
|
|
216
|
+
kept: withId.filter((entry) => !dismissedByFp.has(entry.id)),
|
|
217
|
+
dropped: withId.filter((entry) => dismissedByFp.has(entry.id)),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
const worst = worstDecision(results.map((result) => result.review.decision));
|
|
221
|
+
const unmatched = opts?.unmatchedFiles ?? [];
|
|
222
|
+
const buildBody = (limitPerScope) => {
|
|
223
|
+
const lines = [
|
|
224
|
+
commentMarker(tag),
|
|
225
|
+
"## π€ AI code review",
|
|
226
|
+
"",
|
|
227
|
+
`**Decision:** ${decisionLabel(worst)}`,
|
|
228
|
+
"",
|
|
229
|
+
"| Scope | Decision | Findings |",
|
|
230
|
+
"| --- | --- | --- |",
|
|
231
|
+
];
|
|
232
|
+
for (const { result, kept } of perScope) {
|
|
233
|
+
lines.push(`| ${result.scope} | ${decisionLabel(result.review.decision)} | ${kept.length} |`);
|
|
234
|
+
}
|
|
235
|
+
lines.push("");
|
|
236
|
+
const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
|
|
237
|
+
if (unmatched.length > 0 || anyIncomplete) {
|
|
238
|
+
lines.push("> β±οΈ **Coverage note:** parts of this PR may not be fully reviewed:");
|
|
239
|
+
if (unmatched.length > 0) {
|
|
240
|
+
const shown = unmatched
|
|
241
|
+
.slice(0, 10)
|
|
242
|
+
.map((file) => `\`${file}\``)
|
|
243
|
+
.join(", ");
|
|
244
|
+
const more = unmatched.length > 10 ? `, β¦(+${unmatched.length - 10} more)` : "";
|
|
245
|
+
lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
|
|
246
|
+
}
|
|
247
|
+
for (const result of results) {
|
|
248
|
+
for (const note of result.review.incomplete) {
|
|
249
|
+
lines.push(`> - [${result.scope}] ${note}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
lines.push("");
|
|
253
|
+
}
|
|
254
|
+
// Shown = the most-severe N kept findings per scope (N = limitPerScope). The
|
|
255
|
+
// embedded state trims KEPT findings to the same set so a truncated comment
|
|
256
|
+
// still fits GitHub's body limit (the hidden findings are noted, not silently
|
|
257
|
+
// carried) β but dismissed findings are always kept in state (see below).
|
|
258
|
+
const rendered = perScope.map(({ result, kept, dropped }) => ({
|
|
259
|
+
result,
|
|
260
|
+
shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
|
|
261
|
+
hidden: Math.max(0, kept.length - limitPerScope),
|
|
262
|
+
dropped,
|
|
263
|
+
}));
|
|
264
|
+
for (const { result, shown, hidden } of rendered) {
|
|
265
|
+
const open = shown.length > 0 ? " open" : "";
|
|
266
|
+
const keptCount = shown.length + hidden;
|
|
267
|
+
lines.push(`<details${open}>`, `<summary>${result.scope} β ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
|
|
268
|
+
if (result.review.summary) {
|
|
269
|
+
lines.push(result.review.summary, "");
|
|
270
|
+
}
|
|
271
|
+
if (shown.length === 0) {
|
|
272
|
+
lines.push("No findings.", "");
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
|
|
276
|
+
}
|
|
277
|
+
if (hidden > 0) {
|
|
278
|
+
lines.push(`_β¦and ${hidden} more finding(s) β see the workflow log._`, "");
|
|
279
|
+
}
|
|
280
|
+
lines.push("</details>", "");
|
|
281
|
+
}
|
|
282
|
+
const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
|
|
283
|
+
if (allDropped.length > 0) {
|
|
284
|
+
lines.push("<details>", `<summary>π« Dismissed on this PR (${allDropped.length})</summary>`, "");
|
|
285
|
+
for (const { finding, id, scope } of allDropped) {
|
|
286
|
+
const record = dismissedByFp.get(id);
|
|
287
|
+
const who = record.by ? ` by @${record.by}` : "";
|
|
288
|
+
const why = record.reason ? ` β ${record.reason}` : "";
|
|
289
|
+
lines.push(`- **${finding.title}** β ${location(finding, link)} \`id:${id}\` _(${scope})_${who}${why}`);
|
|
290
|
+
}
|
|
291
|
+
lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
|
|
292
|
+
}
|
|
293
|
+
lines.push("---", "_This review is advisory β it never blocks a merge and never auto-approves._");
|
|
294
|
+
// Embedded state carries the shown (kept) findings PLUS every dismissed one β
|
|
295
|
+
// truncation may trim kept findings so the comment fits, but dismissed findings
|
|
296
|
+
// must survive in state (mirroring renderMarkdown, which embeds the full
|
|
297
|
+
// review) so /undismiss can restore them and the Dismissed section persists
|
|
298
|
+
// across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
|
|
299
|
+
// keep both v2 and v1 consumers working.
|
|
300
|
+
const stateScopes = rendered.map(({ result, shown, dropped }) => ({
|
|
301
|
+
scope: result.scope,
|
|
302
|
+
isDefault: result.isDefault,
|
|
303
|
+
review: { ...result.review, findings: [...shown, ...dropped.map((entry) => entry.finding)] },
|
|
304
|
+
}));
|
|
305
|
+
const merged = {
|
|
306
|
+
decision: worst,
|
|
307
|
+
findings: stateScopes.flatMap((scope) => scope.review.findings),
|
|
308
|
+
summary: results
|
|
309
|
+
.map((result) => `**${result.scope}:** ${result.review.summary}`)
|
|
310
|
+
.join("\n\n"),
|
|
311
|
+
incomplete: [...new Set(results.flatMap((result) => result.review.incomplete))],
|
|
312
|
+
};
|
|
313
|
+
const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
|
|
314
|
+
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
315
|
+
lines.push(`<!-- ${tag}:state=${encodeState({ review: merged, dismissed, scopes: stateScopes })} -->`);
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
};
|
|
318
|
+
let limit = Number.POSITIVE_INFINITY;
|
|
319
|
+
let body = buildBody(limit);
|
|
320
|
+
const largestScope = Math.max(0, ...perScope.map((entry) => entry.kept.length));
|
|
321
|
+
while (body.length > MAX_COMMENT_CHARS && limit > 3) {
|
|
322
|
+
limit =
|
|
323
|
+
limit === Number.POSITIVE_INFINITY
|
|
324
|
+
? Math.max(3, Math.floor(largestScope / 2))
|
|
325
|
+
: Math.max(3, Math.floor(limit / 2));
|
|
326
|
+
body = buildBody(limit);
|
|
327
|
+
}
|
|
328
|
+
return body;
|
|
329
|
+
}
|
|
176
330
|
function encodeState(state) {
|
|
177
|
-
return Buffer.from(JSON.stringify(state),
|
|
331
|
+
return Buffer.from(JSON.stringify(state), "utf8").toString("base64");
|
|
178
332
|
}
|
|
179
333
|
/** Recover the embedded `{ review, dismissed }` state from a posted comment body. */
|
|
180
334
|
export function parseReviewState(body, tag) {
|
|
181
|
-
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g,
|
|
335
|
+
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
182
336
|
const match = body.match(new RegExp(`<!-- ${escapedTag}:state=([A-Za-z0-9+/=]+) -->`));
|
|
183
337
|
if (!match) {
|
|
184
338
|
return null;
|
|
185
339
|
}
|
|
186
340
|
try {
|
|
187
|
-
const parsed = JSON.parse(Buffer.from(match[1],
|
|
341
|
+
const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
|
|
188
342
|
if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
|
|
189
343
|
return parsed;
|
|
190
344
|
}
|
|
@@ -196,11 +350,11 @@ export function parseReviewState(body, tag) {
|
|
|
196
350
|
}
|
|
197
351
|
function severityHeading(severity) {
|
|
198
352
|
switch (severity) {
|
|
199
|
-
case
|
|
200
|
-
return
|
|
201
|
-
case
|
|
202
|
-
return
|
|
203
|
-
case
|
|
204
|
-
return
|
|
353
|
+
case "critical":
|
|
354
|
+
return "π΄ Critical";
|
|
355
|
+
case "warning":
|
|
356
|
+
return "π‘ Warning";
|
|
357
|
+
case "suggestion":
|
|
358
|
+
return "π΅ Suggestion";
|
|
205
359
|
}
|
|
206
360
|
}
|