@jonathan0823/pi-sonarqube 0.5.0 → 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 +2 -2
- package/dist/api.d.ts +15 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +80 -25
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +4 -2
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +260 -20
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -37
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +42 -13
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +154 -66
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +130 -33
- package/src/commands.ts +368 -27
- package/src/index.ts +117 -56
- package/src/types.ts +5 -1
- package/src/ui.ts +224 -80
package/src/commands.ts
CHANGED
|
@@ -17,9 +17,22 @@ import {
|
|
|
17
17
|
SONAR_TYPES,
|
|
18
18
|
SONAR_SOFTWARE_QUALITIES,
|
|
19
19
|
SONAR_IMPACT_SEVERITIES,
|
|
20
|
+
CleanCodeMode,
|
|
20
21
|
} from "./types.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { readdirSync } from "node:fs";
|
|
24
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
25
|
+
import {
|
|
26
|
+
looksLikePath,
|
|
27
|
+
loadWorkspaceRegistry,
|
|
28
|
+
knownTargets,
|
|
29
|
+
} from "./config.js";
|
|
30
|
+
import {
|
|
31
|
+
parseSonarIssueArgs,
|
|
32
|
+
issueFilterLabel,
|
|
33
|
+
fetchProjectProfiles,
|
|
34
|
+
fetchRuleSearch,
|
|
35
|
+
} from "./api.js";
|
|
23
36
|
|
|
24
37
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
25
38
|
|
|
@@ -110,9 +123,7 @@ export function splitSonarArgumentContext(argumentText: string): {
|
|
|
110
123
|
};
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
function createFilterCompletionList(
|
|
114
|
-
mode?: "STANDARD" | "MQR",
|
|
115
|
-
): AutocompleteItem[] {
|
|
126
|
+
function createFilterCompletionList(mode?: CleanCodeMode): AutocompleteItem[] {
|
|
116
127
|
const buildItems = (
|
|
117
128
|
groups: ReadonlyArray<
|
|
118
129
|
readonly [string, readonly string[], string, boolean]
|
|
@@ -138,17 +149,332 @@ function createFilterCompletionList(
|
|
|
138
149
|
["quality", SONAR_SOFTWARE_QUALITIES, "software quality (MQR)", true],
|
|
139
150
|
["impactSeverity", SONAR_IMPACT_SEVERITIES, "impact severity (MQR)", false],
|
|
140
151
|
] as const;
|
|
152
|
+
const ruleItems = [
|
|
153
|
+
{ value: "rule:", label: "rule:", description: "rule key" },
|
|
154
|
+
{ value: "rules:", label: "rules:", description: "rule key" },
|
|
155
|
+
];
|
|
156
|
+
const scopeItems = [
|
|
157
|
+
{
|
|
158
|
+
value: "in:",
|
|
159
|
+
label: "in:",
|
|
160
|
+
description: "path or dir scope (e.g. in:src/api.ts or in:src/)",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
141
163
|
|
|
142
164
|
return mode === "MQR"
|
|
143
|
-
? [
|
|
144
|
-
|
|
165
|
+
? [
|
|
166
|
+
...scopeItems,
|
|
167
|
+
...ruleItems,
|
|
168
|
+
...buildItems(mqrGroups),
|
|
169
|
+
...buildItems(legacyGroups),
|
|
170
|
+
]
|
|
171
|
+
: [
|
|
172
|
+
...scopeItems,
|
|
173
|
+
...ruleItems,
|
|
174
|
+
...buildItems(legacyGroups),
|
|
175
|
+
...buildItems(mqrGroups),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function listDirCompletions(
|
|
180
|
+
dirPath: string,
|
|
181
|
+
baseDir: string,
|
|
182
|
+
): AutocompleteItem[] | null {
|
|
183
|
+
try {
|
|
184
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
185
|
+
const items: AutocompleteItem[] = [];
|
|
186
|
+
const relativePrefix =
|
|
187
|
+
dirPath === baseDir ? "" : relative(baseDir, dirPath) + "/";
|
|
188
|
+
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (entry.name.startsWith(".")) continue;
|
|
191
|
+
const isDir = entry.isDirectory();
|
|
192
|
+
const name = isDir ? entry.name + "/" : entry.name;
|
|
193
|
+
const fullPath = relativePrefix + name;
|
|
194
|
+
items.push({
|
|
195
|
+
value: `in:${fullPath}`,
|
|
196
|
+
label: name,
|
|
197
|
+
description: relativePrefix.replace(/\/$/, ""),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
items.sort((a, b) => {
|
|
202
|
+
const aDir = a.description === "" ? 0 : 1;
|
|
203
|
+
const bDir = b.description === "" ? 0 : 1;
|
|
204
|
+
if (aDir !== bDir) return aDir - bDir;
|
|
205
|
+
// Within same depth, dirs first, then by label
|
|
206
|
+
const aD = a.label.endsWith("/") ? 0 : 1;
|
|
207
|
+
const bD = b.label.endsWith("/") ? 0 : 1;
|
|
208
|
+
return aD - bD || a.label.localeCompare(b.label);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return items.length > 0 ? items.slice(0, 50) : null;
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fuzzyWithCmd(
|
|
218
|
+
cmd: string,
|
|
219
|
+
args: string[],
|
|
220
|
+
prefix: string,
|
|
221
|
+
baseDir: string,
|
|
222
|
+
): AutocompleteItem[] | null {
|
|
223
|
+
const out = execFileSync(cmd, args, {
|
|
224
|
+
encoding: "utf8",
|
|
225
|
+
timeout: 3000,
|
|
226
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
227
|
+
windowsHide: true,
|
|
228
|
+
});
|
|
229
|
+
const lines = out.trim().split("\n").filter(Boolean);
|
|
230
|
+
if (lines.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
const lowerPrefix = prefix.toLowerCase();
|
|
233
|
+
const matched: AutocompleteItem[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
if (matched.length >= 50) break;
|
|
238
|
+
// Normalize: fd outputs dirs with trailing /, rg doesn't
|
|
239
|
+
const isDir = line.endsWith("/");
|
|
240
|
+
const entry = isDir ? line.slice(0, -1) : line;
|
|
241
|
+
if (!entry.toLowerCase().includes(lowerPrefix) || seen.has(entry)) continue;
|
|
242
|
+
seen.add(entry);
|
|
243
|
+
|
|
244
|
+
const dir = dirname(entry);
|
|
245
|
+
const name = basename(entry) + (isDir ? "/" : "");
|
|
246
|
+
matched.push({
|
|
247
|
+
value: `in:${line}`,
|
|
248
|
+
label: name,
|
|
249
|
+
description: dir === "." ? "" : dir + "/",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// For rg (no dirs output), extract ancestor dirs that match
|
|
253
|
+
if (!isDir) {
|
|
254
|
+
addAncestorDirMatches(entry, lowerPrefix, matched, seen);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
matched.sort((a, b) => {
|
|
259
|
+
const aDir = a.label.endsWith("/") ? 0 : 1;
|
|
260
|
+
const bDir = b.label.endsWith("/") ? 0 : 1;
|
|
261
|
+
if (aDir !== bDir) return aDir - bDir;
|
|
262
|
+
return a.label.localeCompare(b.label);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return matched;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* For non-directory entries, extract ancestor directories that match the prefix
|
|
270
|
+
* and add them as autocomplete suggestions.
|
|
271
|
+
*/
|
|
272
|
+
function addAncestorDirMatches(
|
|
273
|
+
entry: string,
|
|
274
|
+
lowerPrefix: string,
|
|
275
|
+
matched: AutocompleteItem[],
|
|
276
|
+
seen: Set<string>,
|
|
277
|
+
): void {
|
|
278
|
+
const segs = entry.split("/");
|
|
279
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
280
|
+
if (matched.length >= 50) break;
|
|
281
|
+
const dirPath = segs.slice(0, i + 1).join("/") + "/";
|
|
282
|
+
if (seen.has(dirPath)) continue;
|
|
283
|
+
seen.add(dirPath);
|
|
284
|
+
if (segs[i].toLowerCase().includes(lowerPrefix)) {
|
|
285
|
+
const parentDir = segs.slice(0, i).join("/");
|
|
286
|
+
matched.push({
|
|
287
|
+
value: `in:${dirPath}`,
|
|
288
|
+
label: segs[i] + "/",
|
|
289
|
+
description: parentDir || "",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function fetchRuleAutocompleteCompletions(
|
|
296
|
+
serverUrl: string,
|
|
297
|
+
token: string | undefined,
|
|
298
|
+
projectKey: string,
|
|
299
|
+
query: string,
|
|
300
|
+
): Promise<AutocompleteItem[] | null> {
|
|
301
|
+
try {
|
|
302
|
+
const profiles = await fetchProjectProfiles(serverUrl, token, projectKey);
|
|
303
|
+
const languages = [
|
|
304
|
+
...new Set(profiles.map((p) => p.language).filter(Boolean)),
|
|
305
|
+
];
|
|
306
|
+
const rules = await fetchRuleSearch(serverUrl, token, query, languages);
|
|
307
|
+
if (rules.length === 0) return null;
|
|
308
|
+
return rules.map((r) => ({
|
|
309
|
+
value: `rule:${r.key}`,
|
|
310
|
+
label: r.key,
|
|
311
|
+
description: r.name ?? "",
|
|
312
|
+
}));
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getInPathCompletions(
|
|
319
|
+
prefix: string,
|
|
320
|
+
baseDir: string,
|
|
321
|
+
): AutocompleteItem[] | null {
|
|
322
|
+
// Directory browsing: show contents when prefix ends with /
|
|
323
|
+
if (prefix.endsWith("/")) {
|
|
324
|
+
return listDirCompletions(resolve(baseDir, prefix), baseDir);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Empty prefix: show root directory contents
|
|
328
|
+
if (!prefix) {
|
|
329
|
+
return listDirCompletions(baseDir, baseDir);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Try fd first (fast, respects .gitignore, matches Pi TUI behavior)
|
|
333
|
+
try {
|
|
334
|
+
return fuzzyWithCmd(
|
|
335
|
+
"fd",
|
|
336
|
+
[
|
|
337
|
+
"--base-directory",
|
|
338
|
+
baseDir,
|
|
339
|
+
"--type",
|
|
340
|
+
"f",
|
|
341
|
+
"--type",
|
|
342
|
+
"d",
|
|
343
|
+
"--hidden",
|
|
344
|
+
"--exclude",
|
|
345
|
+
".git",
|
|
346
|
+
"--max-results",
|
|
347
|
+
"50",
|
|
348
|
+
"--full-path",
|
|
349
|
+
prefix,
|
|
350
|
+
],
|
|
351
|
+
prefix,
|
|
352
|
+
baseDir,
|
|
353
|
+
);
|
|
354
|
+
} catch {
|
|
355
|
+
// fd not available
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Fallback: rg --files (slower but available on most systems)
|
|
359
|
+
try {
|
|
360
|
+
return fuzzyWithCmd(
|
|
361
|
+
"rg",
|
|
362
|
+
["--files", "--color", "never", "--no-ignore-parent"],
|
|
363
|
+
prefix,
|
|
364
|
+
baseDir,
|
|
365
|
+
);
|
|
366
|
+
} catch {
|
|
367
|
+
// rg not available
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Last resort: list current directory
|
|
371
|
+
return listDirCompletions(baseDir, baseDir);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getOpenCompletions(
|
|
375
|
+
issues: SonarIssue[] | undefined,
|
|
376
|
+
mode: CleanCodeMode | undefined,
|
|
377
|
+
current: string,
|
|
378
|
+
): AutocompleteItem[] | null {
|
|
379
|
+
const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
|
|
380
|
+
const lineSuffix = issue.line ? `:${issue.line}` : "";
|
|
381
|
+
return {
|
|
382
|
+
value: String(index + 1),
|
|
383
|
+
label: String(index + 1),
|
|
384
|
+
description: `${issue.severity} ${issue.filePath}${lineSuffix}`,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
|
|
388
|
+
const filtered = filterAutocompleteItems(suggestions, current);
|
|
389
|
+
return filtered.length > 0 ? filtered : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function getFilterCompletions(
|
|
393
|
+
current: string,
|
|
394
|
+
mode: CleanCodeMode | undefined,
|
|
395
|
+
cwd: string | undefined,
|
|
396
|
+
serverUrl: string | undefined,
|
|
397
|
+
token: string | undefined,
|
|
398
|
+
projectKey: string | undefined,
|
|
399
|
+
): Promise<AutocompleteItem[] | null> {
|
|
400
|
+
if (current.startsWith("in:") && cwd) {
|
|
401
|
+
return getInPathCompletions(current.slice(3), cwd);
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
(current.startsWith("rule:") || current.startsWith("rules:")) &&
|
|
405
|
+
serverUrl &&
|
|
406
|
+
projectKey
|
|
407
|
+
) {
|
|
408
|
+
const query = current.split(":").slice(1).join(":");
|
|
409
|
+
const ruleItems = await fetchRuleAutocompleteCompletions(
|
|
410
|
+
serverUrl,
|
|
411
|
+
token,
|
|
412
|
+
projectKey,
|
|
413
|
+
query,
|
|
414
|
+
);
|
|
415
|
+
const staticSuggestions = createFilterCompletionList(mode);
|
|
416
|
+
const filtered = filterAutocompleteItems(staticSuggestions, current);
|
|
417
|
+
const combined = [
|
|
418
|
+
...(filtered.length > 0 ? filtered : []),
|
|
419
|
+
...(ruleItems ?? []),
|
|
420
|
+
];
|
|
421
|
+
return combined.length > 0 ? combined : null;
|
|
422
|
+
}
|
|
423
|
+
const suggestions = createFilterCompletionList(mode);
|
|
424
|
+
const filtered = filterAutocompleteItems(suggestions, current);
|
|
425
|
+
return filtered.length > 0 ? filtered : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function getAnalyzeCompletions(
|
|
429
|
+
current: string,
|
|
430
|
+
mode: CleanCodeMode | undefined,
|
|
431
|
+
cwd: string | undefined,
|
|
432
|
+
serverUrl: string | undefined,
|
|
433
|
+
token: string | undefined,
|
|
434
|
+
projectKey: string | undefined,
|
|
435
|
+
): Promise<AutocompleteItem[] | null> {
|
|
436
|
+
const [filterItems, aliasItems] = await Promise.all([
|
|
437
|
+
getFilterCompletions(current, mode, cwd, serverUrl, token, projectKey),
|
|
438
|
+
cwd ? getWorkspaceAliases(cwd, current) : Promise.resolve(null),
|
|
439
|
+
]);
|
|
440
|
+
if (!filterItems && !aliasItems) return null;
|
|
441
|
+
const combined: AutocompleteItem[] = [
|
|
442
|
+
...(aliasItems ?? []),
|
|
443
|
+
...(filterItems ?? []),
|
|
444
|
+
];
|
|
445
|
+
return combined.length > 0 ? combined : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function getWorkspaceAliases(
|
|
449
|
+
cwd: string,
|
|
450
|
+
current: string,
|
|
451
|
+
): Promise<AutocompleteItem[] | null> {
|
|
452
|
+
try {
|
|
453
|
+
const { registry } = await loadWorkspaceRegistry(cwd);
|
|
454
|
+
const aliases = knownTargets(registry);
|
|
455
|
+
if (aliases.length === 0) return null;
|
|
456
|
+
return filterAutocompleteItems(
|
|
457
|
+
aliases.map((alias) => ({
|
|
458
|
+
value: alias,
|
|
459
|
+
label: alias,
|
|
460
|
+
description: registry.workspaces[alias],
|
|
461
|
+
})),
|
|
462
|
+
current,
|
|
463
|
+
);
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
145
467
|
}
|
|
146
468
|
|
|
147
|
-
export function sonarArgumentCompletions(
|
|
469
|
+
export async function sonarArgumentCompletions(
|
|
148
470
|
argumentText: string,
|
|
149
471
|
issues?: SonarIssue[],
|
|
150
|
-
mode?:
|
|
151
|
-
|
|
472
|
+
mode?: CleanCodeMode,
|
|
473
|
+
cwd?: string,
|
|
474
|
+
serverUrl?: string,
|
|
475
|
+
token?: string,
|
|
476
|
+
projectKey?: string,
|
|
477
|
+
): Promise<AutocompleteItem[] | null> {
|
|
152
478
|
const { command, current, tokens } = splitSonarArgumentContext(argumentText);
|
|
153
479
|
const lowerCommand = command.toLowerCase();
|
|
154
480
|
const commandMatches = SONAR_COMMANDS.filter((item) =>
|
|
@@ -168,23 +494,29 @@ export function sonarArgumentCompletions(
|
|
|
168
494
|
}
|
|
169
495
|
|
|
170
496
|
if (lowerCommand === "open") {
|
|
171
|
-
|
|
172
|
-
const lineSuffix = issue.line ? `:${issue.line}` : "";
|
|
173
|
-
return {
|
|
174
|
-
value: String(index + 1),
|
|
175
|
-
label: String(index + 1),
|
|
176
|
-
description: `${issue.severity} ${issue.filePath}${lineSuffix}`,
|
|
177
|
-
};
|
|
178
|
-
});
|
|
179
|
-
const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
|
|
180
|
-
const filtered = filterAutocompleteItems(suggestions, current);
|
|
181
|
-
return filtered.length > 0 ? filtered : null;
|
|
497
|
+
return getOpenCompletions(issues, mode, current);
|
|
182
498
|
}
|
|
183
499
|
|
|
184
|
-
if (lowerCommand === "analyze"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
500
|
+
if (lowerCommand === "analyze") {
|
|
501
|
+
return getAnalyzeCompletions(
|
|
502
|
+
current,
|
|
503
|
+
mode,
|
|
504
|
+
cwd,
|
|
505
|
+
serverUrl,
|
|
506
|
+
token,
|
|
507
|
+
projectKey,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (lowerCommand === "issues") {
|
|
512
|
+
return getFilterCompletions(
|
|
513
|
+
current,
|
|
514
|
+
mode,
|
|
515
|
+
cwd,
|
|
516
|
+
serverUrl,
|
|
517
|
+
token,
|
|
518
|
+
projectKey,
|
|
519
|
+
);
|
|
188
520
|
}
|
|
189
521
|
|
|
190
522
|
if (
|
|
@@ -218,7 +550,12 @@ export type ParsedSonarCommand =
|
|
|
218
550
|
filters?: SonarIssueFetchOptions;
|
|
219
551
|
}
|
|
220
552
|
| { action: "metrics"; targetInput?: string }
|
|
221
|
-
| {
|
|
553
|
+
| {
|
|
554
|
+
action: "duplications";
|
|
555
|
+
targetInput?: string;
|
|
556
|
+
issueIndex?: number;
|
|
557
|
+
filters?: SonarIssueFetchOptions;
|
|
558
|
+
};
|
|
222
559
|
|
|
223
560
|
export function parseCommandArgs(args: string): ParsedSonarCommand {
|
|
224
561
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -273,6 +610,9 @@ export function helpText(): string {
|
|
|
273
610
|
"Filters:",
|
|
274
611
|
" /sonarqube issues be CRITICAL",
|
|
275
612
|
" /sonarqube issues be severity:CRITICAL status:OPEN",
|
|
613
|
+
" /sonarqube issues be rule:S1192",
|
|
614
|
+
" /sonarqube issues in:src/api.ts",
|
|
615
|
+
" /sonarqube issues in:src/",
|
|
276
616
|
" /sonarqube issues be quality:RELIABILITY",
|
|
277
617
|
" /sonarqube issues be quality:SECURITY impactSeverity:HIGH",
|
|
278
618
|
"",
|
|
@@ -286,6 +626,7 @@ export function helpText(): string {
|
|
|
286
626
|
"Defaults:",
|
|
287
627
|
" config is stored in .pi/sonarqube.json",
|
|
288
628
|
" use project paths directly (no alias needed)",
|
|
629
|
+
" issue and duplication browsers search file, rule, severity, status, message, and stats",
|
|
289
630
|
].join("\n");
|
|
290
631
|
}
|
|
291
632
|
|
|
@@ -314,7 +655,7 @@ export function formatMetricsOutput(metrics: {
|
|
|
314
655
|
measures?: SonarDuplicationMeasures;
|
|
315
656
|
issueCounts?: IssueSeverityCounts;
|
|
316
657
|
issueQualityCounts?: IssueQualityCounts;
|
|
317
|
-
cleanCodeMode?:
|
|
658
|
+
cleanCodeMode?: CleanCodeMode;
|
|
318
659
|
}): string {
|
|
319
660
|
const lines: string[] = [`Metrics for ${metrics.projectKey}`];
|
|
320
661
|
|