@aikidosec/ci-api-client 1.0.18 → 1.0.19

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 CHANGED
@@ -53,41 +53,30 @@ Options:
53
53
  -h, --help display help for command
54
54
  ```
55
55
 
56
- ## Use case: pull request checks & gating
56
+ ## Use case: code coverage
57
57
 
58
- The example below shows how to use the 'scan' command. The use case here is to add a red/green check to a pull request based on the difference in files (head vs base commit).
58
+ The example below shows how to use the `upload-coverage` command. The use case here is to upload LCOV or Cobertura coverage reports from your CI pipeline so Aikido can track coverage per commit and branch.
59
59
 
60
60
  ```sh
61
- $ aikido-api-client scan <repository_id or repository_name> <base_commit_id> <head_commit_id>
61
+ $ aikido-api-client upload-coverage \
62
+ --repo-name <owner/repo> \
63
+ --commit-sha <commit_sha> \
64
+ --branch-name <branch_name> \
65
+ --repository-id <repository_id> \
66
+ --file-paths coverage/lcov.info
62
67
  ```
63
68
  ```
64
- Usage: Aikido API Client scan [options] <repository_id> <base_commit_id> <head_commit_id> [branch_name]
65
- Run a scan of an Aikido repo.
66
- Arguments:
67
- repository_id The internal GitHub/Gitlab/Bitbucket/.. repository id you want to scan.
68
- base_commit_id The base commit of the code you want to scan (e.g. the commit where you branched from for your PR or the
69
- initial commit of your repo)
70
- head_commit_id The latest commit you want to include in your scan (e.g. the latest commit id of your pull request)
71
- branch_name The branch name (default: "main")
69
+ Usage: Aikido API Client upload-coverage [options]
70
+ Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).
72
71
  Options:
73
- --pull-request-title <title> Your pull request title
74
- --pull-request-url <url> Your pull request URL
75
- --self-managed-scanners <scanners...> Set the minimum severity level. Accepted options are: LOW, MEDIUM, HIGH and CRITICAL. (choices: "checkov",
76
- "json-sbom")
77
- --expected-amount-json-sboms <amount> The expected amount of json sbombs
78
- --no-fail-on-dependency-scan Don't fail when scanning depedencies. Default is to fail on new CVE
79
- --fail-on-sast-scan Let Aikido fail when new static code analysis issues have been detected
80
- --fail-on-iac-scan Let Aikido fail when new infrastructure as code issues have been detected
81
- --fail-on-secrets-scan Let Aikido fail when new exposed secrets have been detected
82
- --minimum-severity-level <level> Set the minimum severity level. Accepted options are: LOW, MEDIUM, HIGH and CRITICAL. (choices: "LOW",
83
- "MEDIUM", "HIGH", "CRITICAL")
84
- --poll-interval [interval] The poll interval when checking for an updated scan result (preset: 5)
85
- --base-branch <branchname> Optional: base branch for the release gated scan.
86
- -h, --help display help for command
72
+ -r, --repo-name <reponame> Repository name as owner/repo (e.g. org/my-repo)
73
+ -c, --commit-sha <commitsha> The commit SHA for this CI run
74
+ -b, --branch-name <branchname> The branch name for this CI run
75
+ -f, --file-paths <paths...> Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.
76
+ -ri, --repository-id <repositoryid> The scm repository id
77
+ -h, --help display help for command
87
78
  ```
88
79
 
89
-
90
-
91
80
  ## How it works
92
81
 
93
82
  The CLI will spawn a cloud-based scan and then report scan progress. It will exit with exitCode `0` if the scan was successfull (`gate_passed: true`). The process will exit with exitCode `10` if the scan was unsuccesfull (`gate_passed: false`). If anything else goes wrong (e.g. API unavailable, scanning unavailable, other unexpected issue) the process will exit with exitCode `1`.
@@ -79,4 +79,19 @@ export type TUploadResult = {
79
79
  success: number;
80
80
  };
81
81
  export declare function uploadCustomScanResult(data: TUploadApiOptions): Promise<TUploadResult>;
82
+ export type TUploadCodeCoverageFile = {
83
+ filename: string;
84
+ format: 'lcov' | 'cobertura';
85
+ content: string;
86
+ };
87
+ export type TUploadCodeCoverageOptions = {
88
+ repository_id: string | number;
89
+ repo_name: string;
90
+ commit_sha: string;
91
+ branch_name: string;
92
+ repository_source_paths: string[];
93
+ eof: Record<string, number>;
94
+ files: TUploadCodeCoverageFile[];
95
+ };
96
+ export declare function uploadCodeCoverage(data: TUploadCodeCoverageOptions): Promise<any>;
82
97
  export {};
package/lib/aikidoApi.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { gzipSync } from 'node:zlib';
2
3
  import { getApiKey } from './configuration.js';
3
4
  import { outputDebug } from './output.js';
4
5
  const getApiUrl = () => {
@@ -62,3 +63,38 @@ export async function uploadCustomScanResult(data) {
62
63
  outputDebug(resultData);
63
64
  return resultData;
64
65
  }
66
+ export async function uploadCodeCoverage(data) {
67
+ const requestUrl = `${getApiUrl()}/api/integrations/continuous_integration/scan/code_coverage`;
68
+ const requestConfig = {
69
+ method: 'POST',
70
+ data: {
71
+ repository_id: data.repository_id,
72
+ repo_name: data.repo_name,
73
+ commit_sha: data.commit_sha,
74
+ branch_name: data.branch_name,
75
+ repository_source_paths: data.repository_source_paths,
76
+ eof: data.eof,
77
+ files: data.files.map((file) => ({
78
+ filename: file.filename,
79
+ format: file.format,
80
+ content: gzipSync(file.content).toString('base64'),
81
+ })),
82
+ },
83
+ headers: getApiHeaders(),
84
+ };
85
+ outputDebug(`API request: ${requestUrl}`);
86
+ outputDebug({
87
+ ...requestConfig,
88
+ data: {
89
+ ...requestConfig.data,
90
+ files: requestConfig.data.files.map((file) => ({
91
+ filename: file.filename,
92
+ format: file.format,
93
+ content: `[base64 gzip, ${file.content.length} chars]`,
94
+ })),
95
+ },
96
+ });
97
+ const resultData = (await axios(requestUrl, requestConfig)).data;
98
+ outputDebug(resultData);
99
+ return resultData;
100
+ }
@@ -0,0 +1,12 @@
1
+ import { TCoverageFormat } from './reportPaths.js';
2
+ export type TCoverageFilePayload = {
3
+ filename: string;
4
+ format: TCoverageFormat;
5
+ content: string;
6
+ };
7
+ export type TCollectUploadPayloadResult = {
8
+ repository_source_paths: string[];
9
+ eof: Record<string, number>;
10
+ files: TCoverageFilePayload[];
11
+ };
12
+ export declare function collectUploadPayload(filePaths: string[]): Promise<TCollectUploadPayloadResult>;
@@ -0,0 +1,54 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateFilePath } from './paths.js';
4
+ import { loadProjectFiles } from './projectFiles.js';
5
+ import { detectFormatFromFilename, extractCoveredSourcePaths, } from './reportPaths.js';
6
+ import { loadSourceLineFixes } from './sourceLineFixes.js';
7
+ export async function collectUploadPayload(filePaths) {
8
+ if (filePaths.length === 0) {
9
+ throw new Error('No code coverage file(s) provided. Specify at least one path.');
10
+ }
11
+ const project = await loadProjectFiles();
12
+ if (!project || project.files.length === 0) {
13
+ throw new Error('No source files found in this repository. ' +
14
+ 'Run this command from a checked-out repository before uploading coverage.');
15
+ }
16
+ const repositoryRoot = project.root;
17
+ const files = [];
18
+ const coveredPaths = new Set();
19
+ for (const inputPath of filePaths) {
20
+ validateFilePath(inputPath);
21
+ const absolutePath = path.resolve(inputPath);
22
+ const stats = await fs.lstat(absolutePath);
23
+ const realPath = await fs.realpath(absolutePath);
24
+ const relativePath = path.relative(repositoryRoot, realPath);
25
+ if (stats.isSymbolicLink() ||
26
+ !stats.isFile() ||
27
+ relativePath.startsWith('..') ||
28
+ path.isAbsolute(relativePath)) {
29
+ throw new Error(`Coverage file must be a regular file inside the repository: ${inputPath}`);
30
+ }
31
+ const content = await fs.readFile(realPath, 'utf8');
32
+ const format = detectFormatFromFilename(inputPath);
33
+ for (const sourcePath of extractCoveredSourcePaths(content, format, repositoryRoot)) {
34
+ coveredPaths.add(sourcePath);
35
+ }
36
+ files.push({
37
+ filename: path.posix.basename(inputPath.replace(/\\/g, '/')),
38
+ format,
39
+ content,
40
+ });
41
+ }
42
+ const eof = {};
43
+ for (const sourcePath of coveredPaths) {
44
+ const fixes = await loadSourceLineFixes(repositoryRoot, sourcePath);
45
+ if (fixes?.eof != null) {
46
+ eof[sourcePath] = fixes.eof;
47
+ }
48
+ }
49
+ return {
50
+ repository_source_paths: project.files,
51
+ eof,
52
+ files,
53
+ };
54
+ }
@@ -0,0 +1,4 @@
1
+ export declare function validateFilePath(filePath: string): void;
2
+ export declare function isAbsoluteSourcePath(sourcePath: string): boolean;
3
+ export declare function normalizePathSeparators(sourcePath: string): string;
4
+ export declare function normalizeSourcePath(sourcePath: string, repositoryRoot: string): string;
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ export function validateFilePath(filePath) {
3
+ if (filePath.includes('..') || isAbsoluteSourcePath(filePath)) {
4
+ throw new Error('Invalid file path: absolute paths and ".." segments are not allowed');
5
+ }
6
+ }
7
+ export function isAbsoluteSourcePath(sourcePath) {
8
+ const trimmedPath = sourcePath.trim();
9
+ const pathInput = normalizePathSeparators(trimmedPath);
10
+ const windowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
11
+ trimmedPath.startsWith('\\\\') ||
12
+ trimmedPath.startsWith('//');
13
+ const pathApi = windowsPath ? path.win32 : path.posix;
14
+ return pathApi.isAbsolute(pathInput);
15
+ }
16
+ export function normalizePathSeparators(sourcePath) {
17
+ return sourcePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
18
+ }
19
+ export function normalizeSourcePath(sourcePath, repositoryRoot) {
20
+ const trimmedPath = sourcePath.trim();
21
+ const pathInput = normalizePathSeparators(trimmedPath);
22
+ const windowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
23
+ trimmedPath.startsWith('\\\\') ||
24
+ trimmedPath.startsWith('//');
25
+ const pathApi = windowsPath ? path.win32 : path.posix;
26
+ const absolutePath = pathApi.isAbsolute(pathInput);
27
+ const normalizedPath = absolutePath
28
+ ? pathApi.relative(repositoryRoot, pathInput)
29
+ : pathInput;
30
+ if (absolutePath &&
31
+ (!normalizedPath ||
32
+ normalizedPath === '..' ||
33
+ normalizedPath.startsWith(`..${pathApi.sep}`) ||
34
+ pathApi.isAbsolute(normalizedPath))) {
35
+ throw new Error(`Invalid source path outside the repository: ${sourcePath}`);
36
+ }
37
+ return normalizePathSeparators(normalizedPath);
38
+ }
@@ -0,0 +1,7 @@
1
+ export type TProjectFiles = {
2
+ root: string;
3
+ files: string[];
4
+ };
5
+ export declare function loadProjectFiles(): Promise<TProjectFiles | null>;
6
+ export declare function pathStem(sourcePath: string): string;
7
+ export declare function createPathResolver(projectFiles: string[]): (coveragePath: string) => string | null;
@@ -0,0 +1,233 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import ignore from 'ignore';
4
+ const HARDCODED_IGNORES = [
5
+ '.git/',
6
+ 'node_modules/',
7
+ 'vendor/',
8
+ 'coverage/',
9
+ 'dist/',
10
+ 'build/',
11
+ '.venv/',
12
+ 'venv/',
13
+ '__pycache__/',
14
+ '.eggs/',
15
+ '*.egg-info/',
16
+ '.tox/',
17
+ '.mypy_cache/',
18
+ '.pytest_cache/',
19
+ '.next/',
20
+ '.nuxt/',
21
+ '.turbo/',
22
+ '.cache/',
23
+ 'bower_components/',
24
+ '*.png',
25
+ '*.gif',
26
+ '*.jpg',
27
+ '*.jpeg',
28
+ '*.webp',
29
+ '*.ico',
30
+ '*.woff',
31
+ '*.woff2',
32
+ '*.ttf',
33
+ '*.eot',
34
+ '*.mp4',
35
+ '*.mp3',
36
+ '.nyc_output/',
37
+ 'jspm_packages/',
38
+ '.yarn/cache/',
39
+ '.pnpm-store/',
40
+ 'target/',
41
+ 'Pods/',
42
+ ];
43
+ export async function loadProjectFiles() {
44
+ const root = await findGitRoot(process.cwd());
45
+ const ignoreManager = ignore().add(HARDCODED_IGNORES);
46
+ try {
47
+ ignoreManager.add(await fs.readFile(path.join(root, '.gitignore'), 'utf8'));
48
+ }
49
+ catch {
50
+ }
51
+ const files = [];
52
+ await collectFiles(root, '', [{ base: '', ig: ignoreManager }], files);
53
+ const projectFiles = files.filter(isLikelySourceFile);
54
+ return projectFiles.length === 0 ? null : { root, files: projectFiles };
55
+ }
56
+ async function findGitRoot(startDir) {
57
+ const start = path.resolve(startDir);
58
+ let dir = start;
59
+ while (path.dirname(dir) !== dir) {
60
+ try {
61
+ await fs.access(path.join(dir, '.git'));
62
+ return dir;
63
+ }
64
+ catch {
65
+ dir = path.dirname(dir);
66
+ }
67
+ }
68
+ try {
69
+ await fs.access(path.join(dir, '.git'));
70
+ return dir;
71
+ }
72
+ catch {
73
+ return start;
74
+ }
75
+ }
76
+ function isLikelySourceFile(relativePath) {
77
+ const base = relativePath.slice(relativePath.lastIndexOf('/') + 1);
78
+ const lower = base.toLowerCase();
79
+ if (lower === '.gitignore' ||
80
+ lower.endsWith('.lcov') ||
81
+ lower === 'lcov.info' ||
82
+ (lower.endsWith('.info') && lower.includes('lcov')) ||
83
+ lower === 'coverage-final.json' ||
84
+ lower === 'clover.xml' ||
85
+ lower === 'cobertura.xml' ||
86
+ (lower.endsWith('.xml') && lower.includes('cobertura'))) {
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+ async function collectFiles(absoluteDir, relativeDir, ignoreStack, files) {
92
+ let stack = ignoreStack;
93
+ if (relativeDir) {
94
+ try {
95
+ const nested = ignore().add(await fs.readFile(path.join(absoluteDir, '.gitignore'), 'utf8'));
96
+ stack = [...ignoreStack, { base: relativeDir, ig: nested }];
97
+ }
98
+ catch {
99
+ }
100
+ }
101
+ let entries;
102
+ try {
103
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ for (const entry of entries) {
109
+ const relativePath = relativeDir
110
+ ? `${relativeDir}/${entry.name}`
111
+ : entry.name;
112
+ if (isIgnored(relativePath, entry.isDirectory(), stack)) {
113
+ continue;
114
+ }
115
+ if (entry.isDirectory()) {
116
+ await collectFiles(path.join(absoluteDir, entry.name), relativePath, stack, files);
117
+ }
118
+ else if (entry.isFile() || entry.isSymbolicLink()) {
119
+ files.push(relativePath);
120
+ }
121
+ }
122
+ }
123
+ function isIgnored(relativePath, isDirectory, stack) {
124
+ let ignored = false;
125
+ for (const { base, ig } of stack) {
126
+ const testPath = base ? relativePath.slice(base.length + 1) : relativePath;
127
+ if (!testPath) {
128
+ continue;
129
+ }
130
+ const result = ignoreDecision(ig, testPath, isDirectory);
131
+ if (result.ignored) {
132
+ ignored = true;
133
+ }
134
+ else if (result.unignored) {
135
+ ignored = false;
136
+ }
137
+ }
138
+ return ignored;
139
+ }
140
+ function ignoreDecision(ig, testPath, isDirectory) {
141
+ const result = ig.test(testPath);
142
+ if (isDirectory) {
143
+ const directoryResult = ig.test(`${testPath}/`);
144
+ if (directoryResult.ignored || directoryResult.unignored) {
145
+ return directoryResult;
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ export function pathStem(sourcePath) {
151
+ const lastSlashIndex = sourcePath.lastIndexOf('/');
152
+ const lastDotIndex = sourcePath.lastIndexOf('.');
153
+ if (lastDotIndex > lastSlashIndex) {
154
+ return sourcePath.slice(0, lastDotIndex);
155
+ }
156
+ return sourcePath;
157
+ }
158
+ function normalizePath(filePath) {
159
+ return filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
160
+ }
161
+ function isSamePathOrSuffix(fullPath, possibleSuffix) {
162
+ const normalizedFullPath = fullPath.toLowerCase();
163
+ const suffix = possibleSuffix.toLowerCase();
164
+ return (normalizedFullPath === suffix ||
165
+ normalizedFullPath.endsWith(`/${suffix}`));
166
+ }
167
+ function countSharedTrailingSegments(leftPath, rightPath) {
168
+ const leftSegments = leftPath.toLowerCase().split('/');
169
+ const rightSegments = rightPath.toLowerCase().split('/');
170
+ const maxShared = Math.min(leftSegments.length, rightSegments.length);
171
+ let sharedCount = 0;
172
+ while (sharedCount < maxShared &&
173
+ leftSegments[leftSegments.length - 1 - sharedCount] ===
174
+ rightSegments[rightSegments.length - 1 - sharedCount]) {
175
+ sharedCount++;
176
+ }
177
+ return sharedCount;
178
+ }
179
+ function pickBestMatch(coveragePath, candidates) {
180
+ let best = null;
181
+ let bestShared = -1;
182
+ let bestLength = Infinity;
183
+ let ambiguous = false;
184
+ for (const candidate of candidates) {
185
+ const shared = countSharedTrailingSegments(coveragePath, candidate);
186
+ if (shared > bestShared ||
187
+ (shared === bestShared && candidate.length < bestLength)) {
188
+ best = candidate;
189
+ bestShared = shared;
190
+ bestLength = candidate.length;
191
+ ambiguous = false;
192
+ }
193
+ else if (shared === bestShared && candidate.length === bestLength) {
194
+ ambiguous = true;
195
+ }
196
+ }
197
+ return ambiguous ? null : best;
198
+ }
199
+ export function createPathResolver(projectFiles) {
200
+ const byBasename = new Map();
201
+ for (const projectPath of projectFiles) {
202
+ const key = projectPath
203
+ .slice(projectPath.lastIndexOf('/') + 1)
204
+ .toLowerCase();
205
+ const entries = byBasename.get(key);
206
+ if (entries) {
207
+ entries.push(projectPath);
208
+ }
209
+ else {
210
+ byBasename.set(key, [projectPath]);
211
+ }
212
+ }
213
+ return (coveragePath) => {
214
+ const normalizedCoveragePath = normalizePath(coveragePath);
215
+ const key = normalizedCoveragePath
216
+ .slice(normalizedCoveragePath.lastIndexOf('/') + 1)
217
+ .toLowerCase();
218
+ const candidates = byBasename.get(key) ?? [];
219
+ const matches = candidates.filter((projectPath) => isSamePathOrSuffix(projectPath, normalizedCoveragePath) ||
220
+ isSamePathOrSuffix(normalizedCoveragePath, projectPath));
221
+ if (matches.length === 0) {
222
+ return null;
223
+ }
224
+ const exactMatch = matches.find((projectPath) => projectPath === normalizedCoveragePath);
225
+ if (exactMatch) {
226
+ return exactMatch;
227
+ }
228
+ if (matches.length === 1) {
229
+ return matches[0];
230
+ }
231
+ return pickBestMatch(normalizedCoveragePath, matches);
232
+ };
233
+ }
@@ -0,0 +1,3 @@
1
+ export type TCoverageFormat = 'lcov' | 'cobertura';
2
+ export declare function detectFormatFromFilename(filename: string): TCoverageFormat;
3
+ export declare function extractCoveredSourcePaths(content: string, format: TCoverageFormat, repositoryRoot: string): string[];
@@ -0,0 +1,34 @@
1
+ import { normalizePathSeparators, normalizeSourcePath } from './paths.js';
2
+ export function detectFormatFromFilename(filename) {
3
+ const lower = normalizePathSeparators(filename).toLowerCase();
4
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
5
+ if (base.endsWith('.lcov') || base.endsWith('.info') || base.includes('lcov')) {
6
+ return 'lcov';
7
+ }
8
+ if (base.endsWith('.xml') || base.includes('cobertura')) {
9
+ return 'cobertura';
10
+ }
11
+ throw new Error(`Could not detect coverage format from filename "${filename}". ` +
12
+ 'Use a name like lcov.info, *.lcov, or *cobertura*.xml.');
13
+ }
14
+ export function extractCoveredSourcePaths(content, format, repositoryRoot) {
15
+ if (format === 'cobertura') {
16
+ return extractCoberturaFilenames(content, repositoryRoot);
17
+ }
18
+ return extractLcovFilenames(content, repositoryRoot);
19
+ }
20
+ function extractLcovFilenames(content, repositoryRoot) {
21
+ return [...content.matchAll(/^SF:([^\r\n]+)$/gm)]
22
+ .map((match) => normalizeSourcePath(match[1], repositoryRoot))
23
+ .filter(Boolean);
24
+ }
25
+ function extractCoberturaFilenames(content, repositoryRoot) {
26
+ const paths = [];
27
+ for (const match of content.matchAll(/\bfilename\s*=\s*"([^"]+)"/gi)) {
28
+ const normalized = normalizeSourcePath(match[1], repositoryRoot);
29
+ if (normalized) {
30
+ paths.push(normalized);
31
+ }
32
+ }
33
+ return paths;
34
+ }
@@ -0,0 +1,14 @@
1
+ export declare function loadSourceLineFixes(repositoryRoot: string, sourcePath: string): Promise<{
2
+ eof: number;
3
+ } | null>;
4
+ type TCoverageRecord = {
5
+ lines: Map<number, unknown>;
6
+ functions: Map<string, {
7
+ line: number;
8
+ }>;
9
+ branches: Map<string, unknown>;
10
+ };
11
+ export declare function applySourceLineFixes(record: TCoverageRecord, lineFixes: {
12
+ eof: number;
13
+ } | null): TCoverageRecord;
14
+ export {};
@@ -0,0 +1,48 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ export async function loadSourceLineFixes(repositoryRoot, sourcePath) {
4
+ if (!repositoryRoot || !sourcePath) {
5
+ return null;
6
+ }
7
+ try {
8
+ const base = path.resolve(repositoryRoot);
9
+ const target = path.resolve(base, sourcePath);
10
+ const relative = path.relative(base, target);
11
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
12
+ return null;
13
+ }
14
+ const stats = await fs.lstat(target);
15
+ if (stats.isSymbolicLink() || !stats.isFile()) {
16
+ return null;
17
+ }
18
+ const content = await fs.readFile(target, 'utf8');
19
+ const lineCount = content.split(/\r?\n/).length;
20
+ return { eof: lineCount };
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function applySourceLineFixes(record, lineFixes) {
27
+ if (!lineFixes) {
28
+ return record;
29
+ }
30
+ const { eof } = lineFixes;
31
+ for (const lineNumber of record.lines.keys()) {
32
+ if (lineNumber < 1 || lineNumber > eof) {
33
+ record.lines.delete(lineNumber);
34
+ }
35
+ }
36
+ for (const [functionName, meta] of record.functions.entries()) {
37
+ if (meta.line < 1 || meta.line > eof) {
38
+ record.functions.delete(functionName);
39
+ }
40
+ }
41
+ for (const branchKey of record.branches.keys()) {
42
+ const lineNumber = Number(branchKey.split('\0')[0]);
43
+ if (lineNumber < 1 || lineNumber > eof) {
44
+ record.branches.delete(branchKey);
45
+ }
46
+ }
47
+ return record;
48
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander';
2
+ type TUploadCoverageUserCliOptions = {
3
+ repoName: string;
4
+ commitSha: string;
5
+ branchName: string;
6
+ filePaths: string[];
7
+ repositoryId: string | number;
8
+ };
9
+ declare function cli(options: TUploadCoverageUserCliOptions): Promise<void>;
10
+ export declare const cliSetup: (program: Command) => Command;
11
+ declare const _default: {
12
+ cli: typeof cli;
13
+ cliSetup: (program: Command) => Command;
14
+ };
15
+ export default _default;
@@ -0,0 +1,63 @@
1
+ import { uploadCodeCoverage } from '../aikidoApi.js';
2
+ import { collectUploadPayload } from '../codeCoverage/collectUploadPayload.js';
3
+ import { getApiKey } from '../configuration.js';
4
+ import { outputError, outputHttpError, outputLog, startSpinner, } from '../output.js';
5
+ async function cli(options) {
6
+ const apiKey = getApiKey();
7
+ if (!apiKey) {
8
+ outputError('Please set an api key using: aikido-api-client apikey <key>');
9
+ return;
10
+ return;
11
+ }
12
+ const filePaths = normalizeFilePaths(options.filePaths);
13
+ if (filePaths.length === 0) {
14
+ outputError('No code coverage file(s) provided. Specify at least one path with --file-paths.');
15
+ }
16
+ let loader = startSpinner('Collecting coverage files and repository metadata');
17
+ let payload;
18
+ try {
19
+ payload = await collectUploadPayload(filePaths);
20
+ }
21
+ catch (error) {
22
+ loader?.fail();
23
+ outputError(error instanceof Error ? error.message : String(error));
24
+ return;
25
+ }
26
+ loader?.succeed(`Found ${payload.files.length} coverage file(s) (source paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length})`);
27
+ for (const file of payload.files) {
28
+ outputLog(` ${file.filename} (${file.format})`);
29
+ }
30
+ loader = startSpinner('Uploading code coverage to Aikido');
31
+ try {
32
+ await uploadCodeCoverage({
33
+ repository_id: options.repositoryId,
34
+ repo_name: options.repoName,
35
+ commit_sha: options.commitSha,
36
+ branch_name: options.branchName,
37
+ repository_source_paths: payload.repository_source_paths,
38
+ eof: payload.eof,
39
+ files: payload.files,
40
+ });
41
+ loader?.succeed('Code coverage upload completed');
42
+ process.exit(0);
43
+ }
44
+ catch (error) {
45
+ loader?.fail();
46
+ outputHttpError(error);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ const normalizeFilePaths = (filePaths) => filePaths
51
+ .flatMap((entry) => entry.split(/\n|\s+|,/))
52
+ .map((filePath) => filePath.trim())
53
+ .filter(Boolean);
54
+ export const cliSetup = (program) => program
55
+ .command('upload-coverage')
56
+ .requiredOption('-r, --repo-name <reponame>', 'Repository name as owner/repo (e.g. org/my-repo)')
57
+ .requiredOption('-c, --commit-sha <commitsha>', 'The commit SHA for this CI run')
58
+ .requiredOption('-b, --branch-name <branchname>', 'The branch name for this CI run')
59
+ .requiredOption('-f, --file-paths <paths...>', 'Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.')
60
+ .requiredOption('-ri, --repository-id <repositoryid>', 'The scm repository id')
61
+ .description('Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).')
62
+ .action(cli);
63
+ export default { cli, cliSetup };