@backstage-community/plugin-code-coverage-backend 0.3.1 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @backstage-community/plugin-code-coverage-backend
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - f5b0d2a: Backstage version bump to v1.32.2
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  ### Patch Changes
package/dist/index.cjs.js CHANGED
@@ -2,647 +2,11 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var express = require('express');
6
- var Router = require('express-promise-router');
7
- var BodyParser = require('body-parser');
8
- var bodyParserXml = require('body-parser-xml');
9
- var catalogClient = require('@backstage/catalog-client');
10
- var backendCommon = require('@backstage/backend-common');
11
- var errors = require('@backstage/errors');
12
- var integration = require('@backstage/integration');
13
- var catalogModel = require('@backstage/catalog-model');
14
- var uuid = require('uuid');
15
- var rootHttpRouter = require('@backstage/backend-defaults/rootHttpRouter');
16
- var backendPluginApi = require('@backstage/backend-plugin-api');
5
+ var router = require('./service/router.cjs.js');
6
+ var plugin = require('./plugin.cjs.js');
17
7
 
18
- function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
19
8
 
20
- var express__default = /*#__PURE__*/_interopDefaultCompat(express);
21
- var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
22
- var BodyParser__default = /*#__PURE__*/_interopDefaultCompat(BodyParser);
23
- var bodyParserXml__default = /*#__PURE__*/_interopDefaultCompat(bodyParserXml);
24
9
 
25
- const calculatePercentage = (available, covered) => {
26
- if (available === 0) {
27
- return 0;
28
- }
29
- return parseFloat((covered / available * 100).toFixed(2));
30
- };
31
- const aggregateCoverage = (c) => {
32
- let availableLine = 0;
33
- let coveredLine = 0;
34
- let availableBranch = 0;
35
- let coveredBranch = 0;
36
- c.files.forEach((f) => {
37
- availableLine += Object.keys(f.lineHits).length;
38
- coveredLine += Object.values(f.lineHits).filter((l) => l > 0).length;
39
- availableBranch += Object.keys(f.branchHits).map((b) => parseInt(b, 10)).map((b) => f.branchHits[b].available).filter(Boolean).reduce((acc, curr) => acc + curr, 0);
40
- coveredBranch += Object.keys(f.branchHits).map((b) => parseInt(b, 10)).map((b) => f.branchHits[b].covered).filter(Boolean).reduce((acc, curr) => acc + curr, 0);
41
- });
42
- return {
43
- timestamp: c.metadata.generationTime,
44
- branch: {
45
- available: availableBranch,
46
- covered: coveredBranch,
47
- missed: availableBranch - coveredBranch,
48
- percentage: calculatePercentage(availableBranch, coveredBranch)
49
- },
50
- line: {
51
- available: availableLine,
52
- covered: coveredLine,
53
- missed: availableLine - coveredLine,
54
- percentage: calculatePercentage(availableLine, coveredLine)
55
- }
56
- };
57
- };
58
- class CoverageUtils {
59
- constructor(scm, urlReader) {
60
- this.scm = scm;
61
- this.urlReader = urlReader;
62
- }
63
- async processCoveragePayload(entity, req) {
64
- const enforceScmFiles = entity.metadata.annotations?.["backstage.io/code-coverage"] === "scm-only" || false;
65
- let sourceLocation = void 0;
66
- let vcs = void 0;
67
- let scmFiles = [];
68
- if (enforceScmFiles) {
69
- try {
70
- const sl = catalogModel.getEntitySourceLocation(entity);
71
- sourceLocation = sl.target;
72
- } catch (e) {
73
- }
74
- if (!sourceLocation) {
75
- throw new errors.InputError(
76
- `No "backstage.io/source-location" annotation on entity ${catalogModel.stringifyEntityRef(
77
- entity
78
- )}`
79
- );
80
- }
81
- vcs = this.scm.byUrl?.(sourceLocation);
82
- if (!vcs) {
83
- throw new errors.InputError(`Unable to determine SCM from ${sourceLocation}`);
84
- }
85
- const scmTree = await this.urlReader.readTree?.(sourceLocation);
86
- if (!scmTree) {
87
- throw new errors.NotFoundError(`Unable to read tree from ${sourceLocation}`);
88
- }
89
- scmFiles = (await scmTree.files()).map((f) => f.path);
90
- }
91
- const body = this.validateRequestBody(req);
92
- if (Object.keys(body).length === 0) {
93
- throw new errors.InputError("Unable to parse body");
94
- }
95
- return {
96
- sourceLocation,
97
- vcs,
98
- scmFiles,
99
- body
100
- };
101
- }
102
- async buildCoverage(entity, sourceLocation, vcs, files) {
103
- return {
104
- metadata: {
105
- vcs: {
106
- type: vcs?.type || "unknown",
107
- location: sourceLocation || "unknown"
108
- },
109
- generationTime: Date.now()
110
- },
111
- entity: {
112
- name: entity.metadata.name,
113
- namespace: entity.metadata.namespace || "default",
114
- kind: entity.kind
115
- },
116
- files
117
- };
118
- }
119
- validateRequestBody(req) {
120
- const contentType = req.headers["content-type"];
121
- if (!contentType) {
122
- throw new errors.InputError("Content-Type header missing");
123
- } else if (!contentType.match(/^text\/xml|plain($|;)/)) {
124
- throw new errors.InputError(
125
- `Content-Type header "${contentType}" not supported, expected "text/xml" or "text/plain" possibly followed by a charset`
126
- );
127
- }
128
- const body = req.body;
129
- if (!body) {
130
- throw new errors.InputError("Missing request body");
131
- }
132
- return body;
133
- }
134
- }
135
-
136
- const migrationsDir = backendCommon.resolvePackagePath(
137
- "@backstage-community/plugin-code-coverage-backend",
138
- "migrations"
139
- );
140
- class CodeCoverageDatabase {
141
- constructor(db) {
142
- this.db = db;
143
- }
144
- static async create(database) {
145
- const knex = await database.getClient();
146
- if (!database.migrations?.skip) {
147
- await knex.migrate.latest({
148
- directory: migrationsDir
149
- });
150
- }
151
- return new CodeCoverageDatabase(knex);
152
- }
153
- async insertCodeCoverage(coverage) {
154
- const codeCoverageId = uuid.v4();
155
- const entity = catalogModel.stringifyEntityRef({
156
- kind: coverage.entity.kind,
157
- namespace: coverage.entity.namespace,
158
- name: coverage.entity.name
159
- });
160
- await this.db("code_coverage").insert({
161
- id: codeCoverageId,
162
- entity,
163
- coverage: JSON.stringify(coverage)
164
- });
165
- return { codeCoverageId };
166
- }
167
- async getCodeCoverage(entity) {
168
- const [result] = await this.db("code_coverage").where({ entity }).orderBy("index", "desc").limit(1).select();
169
- if (!result) {
170
- throw new errors.NotFoundError(
171
- `No coverage for entity '${JSON.stringify(entity)}' found`
172
- );
173
- }
174
- try {
175
- return JSON.parse(result.coverage);
176
- } catch (error) {
177
- throw new Error(`Failed to parse coverage for '${entity}', ${error}`);
178
- }
179
- }
180
- async getHistory(entity, limit) {
181
- const res = await this.db("code_coverage").where({ entity }).orderBy("index", "desc").limit(limit).select();
182
- const history = res.map((r) => JSON.parse(r.coverage)).map((c) => aggregateCoverage(c));
183
- const entityName = catalogModel.parseEntityRef(entity);
184
- return {
185
- entity: {
186
- name: entityName.name,
187
- kind: entityName.kind,
188
- namespace: entityName.namespace
189
- },
190
- history
191
- };
192
- }
193
- }
194
-
195
- class Cobertura {
196
- constructor(logger) {
197
- this.logger = logger;
198
- this.logger = logger;
199
- }
200
- /**
201
- * convert cobertura into shared json coverage format
202
- *
203
- * @param xml - cobertura xml object
204
- * @param scmFiles - list of files that are committed to SCM
205
- */
206
- convert(xml, scmFiles) {
207
- const ppc = xml.coverage.packages?.flatMap((p) => p.package).filter(Boolean).flatMap((p) => p.classes);
208
- const pc = xml.coverage.package?.filter(Boolean).flatMap((p) => p.classes);
209
- const classes = [ppc, pc].flat().filter(Boolean).flatMap((c) => c.class).filter(Boolean);
210
- const jscov = [];
211
- classes.forEach((c) => {
212
- const packageAndFilename = c.$.filename;
213
- const lines = this.extractLines(c);
214
- const lineHits = {};
215
- const branchHits = {};
216
- lines.forEach((l) => {
217
- if (!lineHits[l.number]) {
218
- lineHits[l.number] = 0;
219
- }
220
- lineHits[l.number] += l.hits;
221
- if (l.branch && l["condition-coverage"]) {
222
- const bh = this.parseBranch(l["condition-coverage"]);
223
- if (bh) {
224
- branchHits[l.number] = bh;
225
- }
226
- }
227
- });
228
- const currentFile = scmFiles.map((f) => f.trimEnd()).find((f) => f.endsWith(packageAndFilename));
229
- this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`);
230
- if (scmFiles.length === 0 || Object.keys(lineHits).length > 0 && currentFile) {
231
- jscov.push({
232
- filename: currentFile || packageAndFilename,
233
- branchHits,
234
- lineHits
235
- });
236
- }
237
- });
238
- return jscov;
239
- }
240
- /**
241
- * Parses branch coverage information from condition-coverage
242
- *
243
- * @param condition - condition-coverage value from line coverage
244
- */
245
- parseBranch(condition) {
246
- const pattern = /[0-9\.]+\%\s\(([0-9]+)\/([0-9]+)\)/;
247
- const match = condition.match(pattern);
248
- if (!match) {
249
- return null;
250
- }
251
- const covered = parseInt(match[1], 10);
252
- const available = parseInt(match[2], 10);
253
- return {
254
- covered,
255
- missed: available - covered,
256
- available
257
- };
258
- }
259
- /**
260
- * Extract line hits from a class coverage entry
261
- *
262
- * @param clz - class coverage information
263
- */
264
- extractLines(clz) {
265
- const classLines = clz.lines.flatMap((l) => l.line);
266
- const methodLines = clz.methods?.flatMap((m) => m.method).filter(Boolean).flatMap((m) => m.lines).filter(Boolean).flatMap((l) => l.line).filter(
267
- ({ $: methodLine }) => classLines.some(
268
- ({ $: classLine }) => methodLine.number === classLine.number
269
- ) === false
270
- );
271
- const lines = [classLines, methodLines].flat().filter(Boolean);
272
- const lineHits = lines.map((l) => {
273
- return {
274
- number: parseInt(l.$.number, 10),
275
- hits: parseInt(l.$.hits, 10),
276
- "condition-coverage": l.$["condition-coverage"],
277
- branch: l.$.branch
278
- };
279
- });
280
- return lineHits;
281
- }
282
- }
283
-
284
- class Jacoco {
285
- constructor(logger) {
286
- this.logger = logger;
287
- this.logger = logger;
288
- }
289
- /**
290
- * Converts jacoco into shared json coverage format
291
- *
292
- * @param xml - jacoco xml object
293
- * @param scmFiles - list of files that are committed to SCM
294
- */
295
- convert(xml, scmFiles) {
296
- const jscov = [];
297
- xml.report.package.forEach((r) => {
298
- const packageName = r.$.name;
299
- r.sourcefile.forEach((sf) => {
300
- const fileName = sf.$.name;
301
- const lines = this.extractLines(sf);
302
- const lineHits = {};
303
- const branchHits = {};
304
- lines.forEach((l) => {
305
- if (!lineHits[l.number]) {
306
- lineHits[l.number] = 0;
307
- }
308
- lineHits[l.number] += l.covered_instructions;
309
- const ab = l.covered_branches + l.missed_branches;
310
- if (ab > 0) {
311
- branchHits[l.number] = {
312
- covered: l.covered_branches,
313
- missed: l.missed_branches,
314
- available: ab
315
- };
316
- }
317
- });
318
- const packageAndFilename = `${packageName}/${fileName}`;
319
- const currentFile = scmFiles.map((f) => f.trimEnd()).find((f) => f.endsWith(packageAndFilename));
320
- this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`);
321
- if (scmFiles.length === 0 || Object.keys(lineHits).length > 0 && currentFile) {
322
- jscov.push({
323
- filename: currentFile || packageAndFilename,
324
- branchHits,
325
- lineHits
326
- });
327
- }
328
- });
329
- });
330
- return jscov;
331
- }
332
- extractLines(sourcefile) {
333
- const parsed = [];
334
- sourcefile.line?.forEach((l) => {
335
- parsed.push({
336
- number: parseInt(l.$.nr, 10),
337
- missed_instructions: parseInt(l.$.mi, 10),
338
- covered_instructions: parseInt(l.$.ci, 10),
339
- missed_branches: parseInt(l.$.mb, 10),
340
- covered_branches: parseInt(l.$.cb, 10)
341
- });
342
- });
343
- return parsed;
344
- }
345
- }
346
-
347
- class Lcov {
348
- constructor(logger) {
349
- this.logger = logger;
350
- this.logger = logger;
351
- }
352
- /**
353
- * convert lcov into shared json coverage format
354
- *
355
- * @param raw - raw lcov report
356
- * @param scmFiles - list of files that are committed to SCM
357
- */
358
- convert(raw, scmFiles) {
359
- const lines = raw.split(/\r?\n/);
360
- const jscov = [];
361
- let currentFile = null;
362
- lines.forEach((line) => {
363
- const [section, value] = line.split(":");
364
- switch (section) {
365
- // If the line starts with SF, it's a new file
366
- case "SF":
367
- currentFile = this.processNewFile(value, scmFiles);
368
- break;
369
- // If the line starts with DA, it's a line hit
370
- case "DA":
371
- this.processLineHit(currentFile, value);
372
- break;
373
- // If the line starts with BRDA, it's a branch line
374
- case "BRDA":
375
- this.processBranchHit(currentFile, value);
376
- break;
377
- // If the line starts with end_of_record, it's the end of current file
378
- case "end_of_record":
379
- if (currentFile) {
380
- jscov.push(currentFile);
381
- currentFile = null;
382
- }
383
- break;
384
- }
385
- });
386
- return jscov;
387
- }
388
- /**
389
- * Parses a new file entry
390
- *
391
- * @param file - file name from coverage report
392
- * @param scmFiles - list of files that are committed to SCM
393
- */
394
- processNewFile(file, scmFiles) {
395
- const filename = scmFiles.map((f) => f.trimEnd()).find((f) => file.endsWith(f));
396
- this.logger.debug(`matched ${file} to ${filename}`);
397
- if (scmFiles.length === 0 || filename) {
398
- return {
399
- filename: filename || file,
400
- lineHits: {},
401
- branchHits: {}
402
- };
403
- }
404
- return null;
405
- }
406
- /**
407
- * Parses line coverage information
408
- *
409
- * @param currentFile - current file entry
410
- * @param value - line coverage information
411
- */
412
- processLineHit(currentFile, value) {
413
- if (!currentFile) return;
414
- const [lineNumber, hits] = value.split(",");
415
- currentFile.lineHits[Number(lineNumber)] = Number(hits);
416
- }
417
- /**
418
- * Parses branch coverage information
419
- *
420
- * @param currentFile - current file entry
421
- * @param value - branch coverage information
422
- */
423
- processBranchHit(currentFile, value) {
424
- if (!currentFile) return;
425
- const [lineNumber, , , hits] = value.split(",");
426
- const lineNumberNum = Number(lineNumber);
427
- const isHit = Number(hits) > 0;
428
- const branch = currentFile.branchHits[lineNumberNum] || {
429
- covered: 0,
430
- available: 0,
431
- missed: 0
432
- };
433
- branch.available++;
434
- branch.covered += isHit ? 1 : 0;
435
- branch.missed += isHit ? 0 : 1;
436
- currentFile.branchHits[lineNumberNum] = branch;
437
- }
438
- }
439
-
440
- const makeRouter = async (options) => {
441
- const { config, logger, discovery, database, urlReader } = options;
442
- const codeCoverageDatabase = await CodeCoverageDatabase.create(database);
443
- const codecovUrl = await discovery.getExternalBaseUrl("code-coverage");
444
- const catalogApi = options.catalogApi ?? new catalogClient.CatalogClient({ discoveryApi: discovery });
445
- const scm = integration.ScmIntegrations.fromConfig(config);
446
- const { auth, httpAuth } = backendCommon.createLegacyAuthAdapters(options);
447
- const bodySizeLimit = config.getOptionalString("codeCoverage.bodySizeLimit") ?? "100kb";
448
- bodyParserXml__default.default(BodyParser__default.default);
449
- const router = Router__default.default();
450
- router.use(
451
- BodyParser__default.default.xml({
452
- limit: bodySizeLimit
453
- })
454
- );
455
- router.use(
456
- BodyParser__default.default.text({
457
- limit: bodySizeLimit
458
- })
459
- );
460
- router.use(express__default.default.json());
461
- const utils = new CoverageUtils(scm, urlReader);
462
- router.get("/health", async (_req, res) => {
463
- res.status(200).json({ status: "ok" });
464
- });
465
- router.get("/report", async (req, res) => {
466
- const { entity } = req.query;
467
- const entityLookup = await catalogApi.getEntityByRef(
468
- entity,
469
- await auth.getPluginRequestToken({
470
- onBehalfOf: await httpAuth.credentials(req),
471
- targetPluginId: "catalog"
472
- })
473
- );
474
- if (!entityLookup) {
475
- throw new errors.NotFoundError(`No entity found matching ${entity}`);
476
- }
477
- const stored = await codeCoverageDatabase.getCodeCoverage(entity);
478
- const aggregate = aggregateCoverage(stored);
479
- res.status(200).json({
480
- ...stored,
481
- aggregate: {
482
- line: aggregate.line,
483
- branch: aggregate.branch
484
- }
485
- });
486
- });
487
- router.get("/history", async (req, res) => {
488
- const { entity } = req.query;
489
- const entityLookup = await catalogApi.getEntityByRef(
490
- entity,
491
- await auth.getPluginRequestToken({
492
- onBehalfOf: await httpAuth.credentials(req),
493
- targetPluginId: "catalog"
494
- })
495
- );
496
- if (!entityLookup) {
497
- throw new errors.NotFoundError(`No entity found matching ${entity}`);
498
- }
499
- const { limit } = req.query;
500
- const history = await codeCoverageDatabase.getHistory(
501
- entity,
502
- parseInt(limit?.toString() || "10", 10)
503
- );
504
- res.status(200).json(history);
505
- });
506
- router.get("/file-content", async (req, res) => {
507
- const { entity, path } = req.query;
508
- const entityLookup = await catalogApi.getEntityByRef(
509
- entity,
510
- await auth.getPluginRequestToken({
511
- onBehalfOf: await httpAuth.credentials(req),
512
- targetPluginId: "catalog"
513
- })
514
- );
515
- if (!entityLookup) {
516
- throw new errors.NotFoundError(`No entity found matching ${entity}`);
517
- }
518
- if (!path) {
519
- throw new errors.InputError("Need path query parameter");
520
- }
521
- const sourceLocation = catalogModel.getEntitySourceLocation(entityLookup);
522
- if (!sourceLocation) {
523
- throw new errors.InputError(
524
- `No "backstage.io/source-location" annotation on entity ${entity}`
525
- );
526
- }
527
- const vcs = scm.byUrl(sourceLocation.target);
528
- if (!vcs) {
529
- throw new errors.InputError(`Unable to determine SCM from ${sourceLocation}`);
530
- }
531
- const scmTree = await urlReader.readTree(sourceLocation.target);
532
- const scmFile = (await scmTree.files()).find((f) => f.path === path);
533
- if (!scmFile) {
534
- res.status(404).json({
535
- message: "Couldn't find file in SCM",
536
- file: path,
537
- scm: vcs.title
538
- });
539
- return;
540
- }
541
- const content = await scmFile?.content();
542
- if (!content) {
543
- res.status(400).json({
544
- message: "Couldn't process content of file in SCM",
545
- file: path,
546
- scm: vcs.title
547
- });
548
- return;
549
- }
550
- const data = content.toString();
551
- res.status(200).contentType("text/plain").send(data);
552
- });
553
- router.post("/report", async (req, res) => {
554
- const { entity: entityRef, coverageType } = req.query;
555
- const entity = await catalogApi.getEntityByRef(
556
- entityRef,
557
- await auth.getPluginRequestToken({
558
- onBehalfOf: await httpAuth.credentials(req),
559
- targetPluginId: "catalog"
560
- })
561
- );
562
- if (!entity) {
563
- throw new errors.NotFoundError(`No entity found matching ${entityRef}`);
564
- }
565
- let converter;
566
- if (!coverageType) {
567
- throw new errors.InputError("Need coverageType query parameter");
568
- } else if (coverageType === "jacoco") {
569
- converter = new Jacoco(logger);
570
- } else if (coverageType === "cobertura") {
571
- converter = new Cobertura(logger);
572
- } else if (coverageType === "lcov") {
573
- converter = new Lcov(logger);
574
- } else {
575
- throw new errors.InputError(`Unsupported coverage type '${coverageType}`);
576
- }
577
- const { sourceLocation, vcs, scmFiles, body } = await utils.processCoveragePayload(entity, req);
578
- const files = converter.convert(body, scmFiles);
579
- if (!files || files.length === 0) {
580
- throw new errors.InputError(`Unable to parse body as ${coverageType}`);
581
- }
582
- const coverage = await utils.buildCoverage(
583
- entity,
584
- sourceLocation,
585
- vcs,
586
- files
587
- );
588
- await codeCoverageDatabase.insertCodeCoverage(coverage);
589
- res.status(201).json({
590
- links: [
591
- {
592
- rel: "coverage",
593
- href: `${codecovUrl}/report?entity=${entityRef}`
594
- }
595
- ]
596
- });
597
- });
598
- const middleware = rootHttpRouter.MiddlewareFactory.create({ logger, config });
599
- router.use(middleware.error());
600
- return router;
601
- };
602
- async function createRouter(options) {
603
- const logger = options.logger;
604
- logger.info("Initializing Code Coverage backend");
605
- return makeRouter(options);
606
- }
607
-
608
- const codeCoveragePlugin = backendPluginApi.createBackendPlugin({
609
- pluginId: "code-coverage",
610
- register(env) {
611
- env.registerInit({
612
- deps: {
613
- config: backendPluginApi.coreServices.rootConfig,
614
- logger: backendPluginApi.coreServices.logger,
615
- urlReader: backendPluginApi.coreServices.urlReader,
616
- httpRouter: backendPluginApi.coreServices.httpRouter,
617
- discovery: backendPluginApi.coreServices.discovery,
618
- database: backendPluginApi.coreServices.database
619
- },
620
- async init({
621
- config,
622
- logger,
623
- urlReader,
624
- httpRouter,
625
- discovery,
626
- database
627
- }) {
628
- httpRouter.use(
629
- await createRouter({
630
- config,
631
- logger,
632
- urlReader,
633
- discovery,
634
- database
635
- })
636
- );
637
- httpRouter.addAuthPolicy({
638
- path: "/health",
639
- allow: "unauthenticated"
640
- });
641
- }
642
- });
643
- }
644
- });
645
-
646
- exports.createRouter = createRouter;
647
- exports.default = codeCoveragePlugin;
10
+ exports.createRouter = router.createRouter;
11
+ exports.default = plugin.codeCoveragePlugin;
648
12
  //# sourceMappingURL=index.cjs.js.map