mirror of
https://github.com/ivuorinen/gh-codeql-report.git
synced 2026-01-26 11:44:01 +00:00
Initial commit
This commit is contained in:
41
src/__tests__/auth.test.ts
Normal file
41
src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getGitHubToken } from '../lib/auth.js';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
describe('getGitHubToken', () => {
|
||||
const originalEnv = process.env.GITHUB_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.GITHUB_TOKEN = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return token from GITHUB_TOKEN env var', () => {
|
||||
process.env.GITHUB_TOKEN = 'test-token-from-env';
|
||||
const token = getGitHubToken();
|
||||
expect(token).toBe('test-token-from-env');
|
||||
});
|
||||
|
||||
it('should fall back to gh CLI when GITHUB_TOKEN is not set', () => {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocking requires any type
|
||||
vi.mocked(execSync).mockReturnValue('test-token-from-gh\n' as any);
|
||||
|
||||
const token = getGitHubToken();
|
||||
expect(token).toBe('test-token-from-gh');
|
||||
expect(execSync).toHaveBeenCalledWith('gh auth token', { encoding: 'utf-8' });
|
||||
});
|
||||
|
||||
it('should throw error when neither GITHUB_TOKEN nor gh CLI are available', () => {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('gh not found');
|
||||
});
|
||||
|
||||
expect(() => getGitHubToken()).toThrow(
|
||||
'GitHub token not found. Please set GITHUB_TOKEN environment variable or authenticate with `gh auth login`',
|
||||
);
|
||||
});
|
||||
});
|
||||
290
src/__tests__/cli.test.ts
Normal file
290
src/__tests__/cli.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { Octokit } from 'octokit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { main } from '../cli.js';
|
||||
import { formatAsJSON } from '../formatters/json.js';
|
||||
import { formatAsMarkdown } from '../formatters/markdown.js';
|
||||
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||
import { formatAsText } from '../formatters/text.js';
|
||||
import { getGitHubToken } from '../lib/auth.js';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import { fetchAllAlertsWithDetails } from '../lib/codeql.js';
|
||||
import { getGitHubRepoFromRemote } from '../lib/git.js';
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('octokit');
|
||||
vi.mock('../lib/auth.js');
|
||||
vi.mock('../lib/git.js');
|
||||
vi.mock('../lib/codeql.js');
|
||||
vi.mock('../formatters/json.js');
|
||||
vi.mock('../formatters/text.js');
|
||||
vi.mock('../formatters/markdown.js');
|
||||
vi.mock('../formatters/sarif.js');
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
describe('CLI', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let originalArgv: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock console methods
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Save original argv
|
||||
originalArgv = process.argv;
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(getGitHubToken).mockReturnValue('test-token');
|
||||
vi.mocked(getGitHubRepoFromRemote).mockResolvedValue({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
});
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(formatAsJSON).mockReturnValue('{"mock":"json"}');
|
||||
vi.mocked(formatAsText).mockReturnValue('mock text');
|
||||
vi.mocked(formatAsMarkdown).mockReturnValue('# Mock Markdown');
|
||||
vi.mocked(formatAsSARIF).mockReturnValue('{"mock":"sarif"}');
|
||||
|
||||
// Mock Octokit constructor
|
||||
vi.mocked(Octokit).mockImplementation(() => ({}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original argv
|
||||
process.argv = originalArgv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('successful alert generation', () => {
|
||||
it('should generate JSON report with default settings', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(getGitHubToken).toHaveBeenCalled();
|
||||
expect(getGitHubRepoFromRemote).toHaveBeenCalled();
|
||||
expect(fetchAllAlertsWithDetails).toHaveBeenCalled();
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.json$/),
|
||||
'{"mock":"json"}',
|
||||
'utf-8',
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('✅ Report saved to:'));
|
||||
});
|
||||
|
||||
it('should generate SARIF report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'sarif'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsSARIF).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.sarif$/),
|
||||
'{"mock":"sarif"}',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate text report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'txt'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsText).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.txt$/),
|
||||
'mock text',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate markdown report when format specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--format', 'md'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsMarkdown).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/code-scanning-report-.*\.md$/),
|
||||
'# Mock Markdown',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom output path when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--output', 'custom-report.json'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith('custom-report.json', '{"mock":"json"}', 'utf-8');
|
||||
});
|
||||
|
||||
it('should use minimum detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'minimum'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'minimum');
|
||||
});
|
||||
|
||||
it('should use full detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'full'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'full');
|
||||
});
|
||||
|
||||
it('should use raw detail level when specified', async () => {
|
||||
process.argv = ['node', 'cli.js', '--detail', 'raw'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'raw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no alerts found (celebration)', () => {
|
||||
it('should celebrate and exit with 0 when no alerts found', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([]);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'🎉 No CodeQL alerts found! Your repository is clean!',
|
||||
);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle git remote error and exit with 1', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(getGitHubRepoFromRemote).mockRejectedValue(
|
||||
new Error('No git remotes found. Make sure you are in a git repository.'),
|
||||
);
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'❌ Error: No git remotes found. Make sure you are in a git repository.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(getGitHubToken).mockImplementation(() => {
|
||||
throw new Error('GitHub token not found');
|
||||
});
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: GitHub token not found');
|
||||
});
|
||||
|
||||
it('should handle API error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue(new Error('API request failed'));
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: API request failed');
|
||||
});
|
||||
|
||||
it('should handle file write error', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: Permission denied');
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue('string error');
|
||||
|
||||
const exitCode = await main();
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('console output', () => {
|
||||
it('should log progress messages', async () => {
|
||||
process.argv = ['node', 'cli.js'];
|
||||
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||
|
||||
const exitCode = await main();
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('🔐 Authenticating with GitHub...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('📂 Detecting repository from git remote...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Repository: test-owner/test-repo');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('🔍 Fetching CodeQL alerts...');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(' Found 1 open alert(s)');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('📝 Generating JSON report (medium detail)...');
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/__tests__/codeql.test.ts
Normal file
196
src/__tests__/codeql.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Octokit } from 'octokit';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import { fetchAlertDetails, fetchAllAlertsWithDetails, fetchCodeQLAlerts } from '../lib/codeql.js';
|
||||
import type { GitHubRepo } from '../lib/git.js';
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
const mockRepo: GitHubRepo = {
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
};
|
||||
|
||||
describe('CodeQL API', () => {
|
||||
describe('fetchCodeQLAlerts', () => {
|
||||
it('should stop pagination when empty page received', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: [mockAlert, { ...mockAlert, number: 2 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(2);
|
||||
// Should stop on first call because result is less than perPage (100)
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle single page of alerts', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [mockAlert],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should continue pagination until fewer than perPage results', async () => {
|
||||
const mockAlerts = Array.from({ length: 100 }, (_, i) => ({ ...mockAlert, number: i + 1 }));
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: mockAlerts,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ ...mockAlert, number: 101 }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(101);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAlertDetails', () => {
|
||||
it('should fetch details for a specific alert', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
getAlert: vi.fn().mockResolvedValue({
|
||||
data: mockAlert,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alert = await fetchAlertDetails(mockOctokit, mockRepo, 1);
|
||||
|
||||
expect(alert).toEqual(mockAlert);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledWith({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
alert_number: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllAlertsWithDetails', () => {
|
||||
it('should fetch all alerts and their details', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ ...mockAlert, number: 1 },
|
||||
{ ...mockAlert, number: 2 },
|
||||
],
|
||||
}),
|
||||
getAlert: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...mockAlert, number: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { ...mockAlert, number: 2 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(2);
|
||||
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
codeScanning: {
|
||||
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
getAlert: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||
|
||||
expect(alerts).toHaveLength(0);
|
||||
expect(mockOctokit.rest.codeScanning.getAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
300
src/__tests__/formatters.test.ts
Normal file
300
src/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatAsJSON } from '../formatters/json.js';
|
||||
import { formatAsMarkdown, generateMarkdownTable } from '../formatters/markdown.js';
|
||||
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||
import { formatAsText } from '../formatters/text.js';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
|
||||
const mockAlert: CodeQLAlert = {
|
||||
number: 1,
|
||||
rule: {
|
||||
id: 'js/sql-injection',
|
||||
severity: 'error',
|
||||
description: 'SQL injection vulnerability',
|
||||
name: 'SQL Injection',
|
||||
},
|
||||
most_recent_instance: {
|
||||
ref: 'refs/heads/main',
|
||||
analysis_key: 'test-analysis',
|
||||
category: 'security',
|
||||
state: 'open',
|
||||
commit_sha: 'abc123',
|
||||
message: {
|
||||
text: 'Potential SQL injection detected',
|
||||
},
|
||||
location: {
|
||||
path: 'src/database.js',
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
start_column: 5,
|
||||
end_column: 20,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: 'CodeQL',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Formatters', () => {
|
||||
describe('formatAsJSON', () => {
|
||||
it('should format alerts as JSON with default (medium) detail', () => {
|
||||
const result = formatAsJSON([mockAlert]);
|
||||
expect(result).toContain('"number": 1');
|
||||
expect(result).toContain('"js/sql-injection"');
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should format alerts with minimum detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'minimum');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('number');
|
||||
expect(parsed[0]).toHaveProperty('rule_id');
|
||||
expect(parsed[0]).toHaveProperty('commit_sha'); // Now in all levels
|
||||
expect(parsed[0]).not.toHaveProperty('rule_description');
|
||||
expect(parsed[0]).not.toHaveProperty('rule.id'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should format alerts with medium detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'medium');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('number');
|
||||
expect(parsed[0]).toHaveProperty('rule_description');
|
||||
expect(parsed[0]).toHaveProperty('commit_sha');
|
||||
expect(parsed[0]).toHaveProperty('state');
|
||||
expect(parsed[0]).not.toHaveProperty('ref');
|
||||
expect(parsed[0]).not.toHaveProperty('most_recent_instance'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should format alerts with full detail (flat structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('ref');
|
||||
expect(parsed[0]).toHaveProperty('tool_name');
|
||||
expect(parsed[0]).toHaveProperty('tool_version');
|
||||
expect(parsed[0]).not.toHaveProperty('tool'); // Nested structure removed
|
||||
});
|
||||
|
||||
it('should include help_text in full detail when available', () => {
|
||||
const alertWithHelp = {
|
||||
...mockAlert,
|
||||
help: 'This is a helpful guide on how to fix this issue.',
|
||||
};
|
||||
const result = formatAsJSON([alertWithHelp], 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('help_text');
|
||||
expect(parsed[0].help_text).toBe('This is a helpful guide on how to fix this issue.');
|
||||
});
|
||||
|
||||
it('should format alerts with raw detail (original structure)', () => {
|
||||
const result = formatAsJSON([mockAlert], 'raw');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||
expect(parsed[0]).toHaveProperty('tool');
|
||||
expect(parsed[0]).toHaveProperty('rule');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = formatAsJSON([]);
|
||||
expect(result).toBe('[]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsText', () => {
|
||||
it('should format alerts as text with default (medium) detail', () => {
|
||||
const result = formatAsText([mockAlert]);
|
||||
expect(result).toContain('CodeQL Security Scan Report');
|
||||
expect(result).toContain('Total Alerts: 1');
|
||||
expect(result).toContain('Detail Level: medium');
|
||||
expect(result).toContain('Alert #1');
|
||||
expect(result).toContain('js/sql-injection');
|
||||
expect(result).toContain('SQL Injection');
|
||||
expect(result).toContain('src/database.js');
|
||||
});
|
||||
|
||||
it('should format alerts with minimum detail (commit now included)', () => {
|
||||
const result = formatAsText([mockAlert], 'minimum');
|
||||
expect(result).toContain('Detail Level: minimum');
|
||||
expect(result).toContain('Alert #1');
|
||||
expect(result).toContain('Commit:'); // Now in all levels
|
||||
expect(result).not.toContain('Description:');
|
||||
expect(result).not.toContain('Columns:');
|
||||
expect(result).not.toContain('State:');
|
||||
});
|
||||
|
||||
it('should format alerts with full detail', () => {
|
||||
const result = formatAsText([mockAlert], 'full');
|
||||
expect(result).toContain('Detail Level: full');
|
||||
expect(result).toContain('Description:');
|
||||
expect(result).toContain('Columns:');
|
||||
expect(result).toContain('Commit:');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = formatAsText([]);
|
||||
expect(result).toContain('Total Alerts: 0');
|
||||
});
|
||||
|
||||
it('should format alerts with raw detail (original structure)', () => {
|
||||
const result = formatAsText([mockAlert], 'raw');
|
||||
expect(result).toContain('Detail Level: raw');
|
||||
expect(result).toContain('"most_recent_instance"');
|
||||
expect(result).toContain('"tool"');
|
||||
expect(result).toContain('"rule"');
|
||||
const parsed = JSON.parse(result.split('\n').slice(4, -2).join('\n'));
|
||||
expect(parsed).toHaveProperty('most_recent_instance');
|
||||
expect(parsed).toHaveProperty('tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMarkdownTable', () => {
|
||||
it('should generate a valid markdown table', () => {
|
||||
const data = [
|
||||
['Name', 'Age', 'City'],
|
||||
['Alice', '30', 'NYC'],
|
||||
['Bob', '25', 'LA'],
|
||||
];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Name | Age | City |');
|
||||
expect(result).toContain('| ----- | --- | ---- |');
|
||||
expect(result).toContain('| Alice | 30 | NYC |');
|
||||
expect(result).toContain('| Bob | 25 | LA |');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = generateMarkdownTable([]);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle jagged arrays (rows with missing columns)', () => {
|
||||
const data = [
|
||||
['Name', 'Age', 'City'],
|
||||
['Alice', '30'], // Missing city
|
||||
['Bob', '25', 'LA'],
|
||||
];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Name | Age | City |');
|
||||
expect(result).toContain('| ----- | --- | ---- |');
|
||||
expect(result).toContain('| Alice | 30 | |'); // Empty cell for missing column
|
||||
expect(result).toContain('| Bob | 25 | LA |');
|
||||
});
|
||||
|
||||
it('should handle single row (headers only)', () => {
|
||||
const data = [['Header1', 'Header2']];
|
||||
const result = generateMarkdownTable(data);
|
||||
expect(result).toContain('| Header1 | Header2 |');
|
||||
expect(result).toContain('| ------- | ------- |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsMarkdown', () => {
|
||||
it('should format alerts as markdown with default (medium) detail', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||
expect(result).toContain('# CodeQL Security Scan Report');
|
||||
expect(result).toContain('**Repository:** owner/repo');
|
||||
expect(result).toContain('**Total Alerts:** 1');
|
||||
expect(result).toContain('**Detail Level:** medium');
|
||||
expect(result).toContain('## Summary by Severity');
|
||||
expect(result).toContain('### Alert #1: SQL Injection');
|
||||
expect(result).toContain('`js/sql-injection`');
|
||||
});
|
||||
|
||||
it('should format with minimum detail (commit now included)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'minimum');
|
||||
expect(result).toContain('**Detail Level:** minimum');
|
||||
expect(result).toContain('**Commit:**'); // Now in all levels
|
||||
expect(result).toContain('#### Details'); // Now always present (for commit)
|
||||
expect(result).not.toContain('**Description:**');
|
||||
expect(result).not.toContain('**Columns:**');
|
||||
expect(result).not.toContain('**State:**');
|
||||
});
|
||||
|
||||
it('should format with full detail (includes ref)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'full');
|
||||
expect(result).toContain('**Detail Level:** full');
|
||||
expect(result).toContain('**Reference:**');
|
||||
});
|
||||
|
||||
it('should include severity summary table', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||
expect(result).toContain('Severity');
|
||||
expect(result).toContain('Count');
|
||||
expect(result).toContain('error');
|
||||
});
|
||||
|
||||
it('should format with raw detail (original structure as JSON)', () => {
|
||||
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'raw');
|
||||
expect(result).toContain('**Detail Level:** raw');
|
||||
expect(result).toContain('```json');
|
||||
expect(result).toContain('"most_recent_instance"');
|
||||
expect(result).toContain('"tool"');
|
||||
expect(result).toContain('"rule"');
|
||||
});
|
||||
|
||||
it('should handle multiple alerts with different severities', () => {
|
||||
const alerts: CodeQLAlert[] = [
|
||||
mockAlert,
|
||||
{ ...mockAlert, number: 2, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||
{ ...mockAlert, number: 3, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||
{ ...mockAlert, number: 4, rule: { ...mockAlert.rule, severity: 'note' } },
|
||||
];
|
||||
const result = formatAsMarkdown(alerts, 'owner/repo');
|
||||
expect(result).toContain('**Total Alerts:** 4');
|
||||
expect(result).toContain('error');
|
||||
expect(result).toContain('warning');
|
||||
expect(result).toContain('note');
|
||||
// Should have summary table with all three severities
|
||||
expect(result).toContain('## Summary by Severity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAsSARIF', () => {
|
||||
it('should format alerts as valid SARIF with default (medium) detail', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toHaveProperty('$schema');
|
||||
expect(parsed).toHaveProperty('version');
|
||||
expect(parsed.runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should format with minimum detail', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'minimum');
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should format with full detail (includes tool version)', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'full');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].tool.driver.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('should format with raw detail (returns original JSON)', () => {
|
||||
const result = formatAsSARIF([mockAlert], 'owner/repo', 'raw');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||
expect(parsed[0]).toHaveProperty('tool');
|
||||
});
|
||||
|
||||
it('should map medium severity to warning level', () => {
|
||||
const mediumAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'medium' } };
|
||||
const result = formatAsSARIF([mediumAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||
});
|
||||
|
||||
it('should map warning severity to warning level', () => {
|
||||
const warningAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'warning' } };
|
||||
const result = formatAsSARIF([warningAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||
});
|
||||
|
||||
it('should map unknown severity to note level', () => {
|
||||
const lowAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'low' } };
|
||||
const result = formatAsSARIF([lowAlert], 'owner/repo');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.runs[0].results[0].level).toBe('note');
|
||||
});
|
||||
});
|
||||
});
|
||||
148
src/__tests__/git.test.ts
Normal file
148
src/__tests__/git.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { SimpleGit } from 'simple-git';
|
||||
import simpleGit from 'simple-git';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getGitHubRepoFromRemote, parseGitHubUrl } from '../lib/git.js';
|
||||
|
||||
vi.mock('simple-git');
|
||||
|
||||
describe('parseGitHubUrl', () => {
|
||||
it('should parse HTTPS URL', () => {
|
||||
const result = parseGitHubUrl('https://github.com/owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse HTTPS URL without .git', () => {
|
||||
const result = parseGitHubUrl('https://github.com/owner/repo');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse SSH URL', () => {
|
||||
const result = parseGitHubUrl('git@github.com:owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should parse git:// URL', () => {
|
||||
const result = parseGitHubUrl('git://github.com/owner/repo.git');
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should return null for invalid URL', () => {
|
||||
const result = parseGitHubUrl('not-a-valid-url');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle URLs with hyphens and underscores', () => {
|
||||
const result = parseGitHubUrl('https://github.com/my-org_name/my-repo_name.git');
|
||||
expect(result).toEqual({ owner: 'my-org_name', repo: 'my-repo_name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitHubRepoFromRemote', () => {
|
||||
it('should extract repo from origin remote', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should use first remote if origin not found', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'upstream', refs: { fetch: 'https://github.com/other/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'other', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should use push URL if fetch URL not available', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: '', push: 'git@github.com:owner/repo.git' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
const result = await getGitHubRepoFromRemote();
|
||||
|
||||
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||
});
|
||||
|
||||
it('should throw error if no remotes found', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No git remotes found');
|
||||
});
|
||||
|
||||
it('should throw error if remote has no valid URL', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockResolvedValue([{ name: 'origin', refs: { fetch: '', push: '' } }]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No valid remote URL found');
|
||||
});
|
||||
|
||||
it('should throw error if URL cannot be parsed', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'origin', refs: { fetch: 'not-a-github-url', push: '' } }]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Unable to parse GitHub repository');
|
||||
});
|
||||
|
||||
it('should handle git errors', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockRejectedValue(new Error('Git error')),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Git error');
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi.fn().mockRejectedValue('string error'),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Failed to get git remote information');
|
||||
});
|
||||
|
||||
it('should pass cwd parameter to simpleGit', async () => {
|
||||
const mockGit = {
|
||||
getRemotes: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||
]),
|
||||
};
|
||||
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||
|
||||
await getGitHubRepoFromRemote('/custom/path');
|
||||
|
||||
expect(simpleGit).toHaveBeenCalledWith('/custom/path');
|
||||
});
|
||||
});
|
||||
121
src/cli.ts
Normal file
121
src/cli.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { Octokit } from 'octokit';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { formatAsJSON } from './formatters/json.js';
|
||||
import { formatAsMarkdown } from './formatters/markdown.js';
|
||||
import { formatAsSARIF } from './formatters/sarif.js';
|
||||
import { formatAsText } from './formatters/text.js';
|
||||
import { getGitHubToken } from './lib/auth.js';
|
||||
import { fetchAllAlertsWithDetails } from './lib/codeql.js';
|
||||
import { getGitHubRepoFromRemote } from './lib/git.js';
|
||||
import type { DetailLevel } from './lib/types.js';
|
||||
|
||||
interface Arguments {
|
||||
format: string;
|
||||
output?: string;
|
||||
detail: DetailLevel;
|
||||
}
|
||||
|
||||
export async function main(): Promise<number> {
|
||||
const argv = (await yargs(hideBin(process.argv))
|
||||
.option('format', {
|
||||
alias: 'f',
|
||||
type: 'string',
|
||||
description: 'Output format',
|
||||
choices: ['json', 'sarif', 'txt', 'md'],
|
||||
default: 'json',
|
||||
})
|
||||
.option('detail', {
|
||||
alias: 'd',
|
||||
type: 'string',
|
||||
description:
|
||||
'Detail level: minimum (essentials only), medium (balanced), full (everything), raw (original API response)',
|
||||
choices: ['minimum', 'medium', 'full', 'raw'],
|
||||
default: 'medium',
|
||||
})
|
||||
.option('output', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
description: 'Output file path (optional, defaults to code-scanning-report-[timestamp])',
|
||||
})
|
||||
.help()
|
||||
.alias('help', 'h')
|
||||
.version()
|
||||
.alias('version', 'v')
|
||||
.parse()) as Arguments;
|
||||
|
||||
try {
|
||||
// Get GitHub token
|
||||
console.log('🔐 Authenticating with GitHub...');
|
||||
const token = getGitHubToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
// Get repository info from git remote
|
||||
console.log('📂 Detecting repository from git remote...');
|
||||
const repo = await getGitHubRepoFromRemote();
|
||||
console.log(` Repository: ${repo.owner}/${repo.repo}`);
|
||||
|
||||
// Fetch CodeQL alerts
|
||||
console.log('🔍 Fetching CodeQL alerts...');
|
||||
const alerts = await fetchAllAlertsWithDetails(octokit, repo);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
console.log('🎉 No CodeQL alerts found! Your repository is clean!');
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(` Found ${alerts.length} open alert(s)`);
|
||||
|
||||
// Format the report
|
||||
console.log(`📝 Generating ${argv.format.toUpperCase()} report (${argv.detail} detail)...`);
|
||||
const repoName = `${repo.owner}/${repo.repo}`;
|
||||
let content: string;
|
||||
|
||||
switch (argv.format) {
|
||||
case 'json':
|
||||
content = formatAsJSON(alerts, argv.detail);
|
||||
break;
|
||||
case 'sarif':
|
||||
content = formatAsSARIF(alerts, repoName, argv.detail);
|
||||
break;
|
||||
case 'txt':
|
||||
content = formatAsText(alerts, argv.detail);
|
||||
break;
|
||||
case 'md':
|
||||
content = formatAsMarkdown(alerts, repoName, argv.detail);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported format: ${argv.format}`);
|
||||
}
|
||||
|
||||
// Generate output filename
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.replace(/T/, '-')
|
||||
.split('.')[0];
|
||||
const outputPath = argv.output || `code-scanning-report-${timestamp}.${argv.format}`;
|
||||
|
||||
// Write to file
|
||||
await writeFile(outputPath, content, 'utf-8');
|
||||
console.log(`✅ Report saved to: ${outputPath}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`❌ Error: ${error.message}`);
|
||||
} else {
|
||||
console.error('❌ An unexpected error occurred');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Only run if this is the main module (not imported for testing)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().then((exitCode) => {
|
||||
process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
10
src/formatters/json.ts
Normal file
10
src/formatters/json.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import { type DetailLevel, filterAlertByDetail } from '../lib/types.js';
|
||||
|
||||
/**
|
||||
* Format alerts as JSON
|
||||
*/
|
||||
export function formatAsJSON(alerts: CodeQLAlert[], detailLevel: DetailLevel = 'medium'): string {
|
||||
const filteredAlerts = alerts.map((alert) => filterAlertByDetail(alert, detailLevel));
|
||||
return JSON.stringify(filteredAlerts, null, 2);
|
||||
}
|
||||
157
src/formatters/markdown.ts
Normal file
157
src/formatters/markdown.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import {
|
||||
type DetailLevel,
|
||||
type FullAlert,
|
||||
filterAlertByDetail,
|
||||
type MediumAlert,
|
||||
type MinimumAlert,
|
||||
} from '../lib/types.js';
|
||||
|
||||
/**
|
||||
* Generate a markdown table from 2D array data
|
||||
* Exported for testing edge cases
|
||||
*/
|
||||
export function generateMarkdownTable(data: string[][]): string {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const [headers, ...rows] = data;
|
||||
|
||||
// Calculate column widths
|
||||
const columnWidths = headers.map((header, i) => {
|
||||
const maxWidth = Math.max(header.length, ...rows.map((row) => row[i]?.length || 0));
|
||||
return maxWidth;
|
||||
});
|
||||
|
||||
// Build table
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header row
|
||||
const headerRow = headers.map((header, i) => header.padEnd(columnWidths[i])).join(' | ');
|
||||
lines.push(`| ${headerRow} |`);
|
||||
|
||||
// Separator row
|
||||
const separator = columnWidths.map((width) => '-'.repeat(width)).join(' | ');
|
||||
lines.push(`| ${separator} |`);
|
||||
|
||||
// Data rows
|
||||
for (const row of rows) {
|
||||
// Pad row with empty strings if it's shorter than headers
|
||||
const paddedRow = Array.from({ length: headers.length }, (_, i) => row[i] || '');
|
||||
const dataRow = paddedRow.map((cell, i) => cell.padEnd(columnWidths[i])).join(' | ');
|
||||
lines.push(`| ${dataRow} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format alerts as Markdown
|
||||
*/
|
||||
export function formatAsMarkdown(
|
||||
alerts: CodeQLAlert[],
|
||||
repoName: string,
|
||||
detailLevel: DetailLevel = 'medium',
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# CodeQL Security Scan Report`);
|
||||
lines.push('');
|
||||
lines.push(`**Repository:** ${repoName}`);
|
||||
lines.push(`**Total Alerts:** ${alerts.length}`);
|
||||
lines.push(`**Detail Level:** ${detailLevel}`);
|
||||
lines.push(`**Generated:** ${new Date().toISOString()}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
// Summary table
|
||||
lines.push('## Summary by Severity');
|
||||
lines.push('');
|
||||
|
||||
const severityCounts = alerts.reduce(
|
||||
(acc, alert) => {
|
||||
const severity = alert.rule.severity.toLowerCase();
|
||||
acc[severity] = (acc[severity] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const summaryTableData = [
|
||||
['Severity', 'Count'],
|
||||
...Object.entries(severityCounts).map(([severity, count]) => [severity, count.toString()]),
|
||||
];
|
||||
|
||||
lines.push(generateMarkdownTable(summaryTableData));
|
||||
lines.push('');
|
||||
|
||||
// Detailed alerts
|
||||
lines.push('## Detailed Alerts');
|
||||
lines.push('');
|
||||
|
||||
for (const alert of alerts) {
|
||||
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||
|
||||
// Handle raw format - return as code block
|
||||
if (detailLevel === 'raw') {
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(filtered, null, 2));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type assertion: after raw check, we know filtered is a flattened alert type
|
||||
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||
|
||||
lines.push(`### Alert #${flatAlert.number}: ${flatAlert.rule_name}`);
|
||||
lines.push('');
|
||||
lines.push(`**Rule ID:** \`${flatAlert.rule_id}\``);
|
||||
lines.push(`**Severity:** ${flatAlert.severity}`);
|
||||
|
||||
// Description only in medium and full
|
||||
if ('rule_description' in flatAlert) {
|
||||
lines.push(`**Description:** ${flatAlert.rule_description}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('#### Location');
|
||||
lines.push('');
|
||||
lines.push(`- **File:** \`${flatAlert.file_path}\``);
|
||||
lines.push(`- **Lines:** ${flatAlert.start_line}-${flatAlert.end_line}`);
|
||||
|
||||
// Columns only in medium and full
|
||||
if ('start_column' in flatAlert) {
|
||||
lines.push(`- **Columns:** ${flatAlert.start_column}-${flatAlert.end_column}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('#### Message');
|
||||
lines.push('');
|
||||
lines.push(flatAlert.message);
|
||||
|
||||
// Details section - commit is now in all levels
|
||||
lines.push('');
|
||||
lines.push('#### Details');
|
||||
lines.push('');
|
||||
lines.push(`- **Commit:** \`${flatAlert.commit_sha}\``);
|
||||
|
||||
// State only in medium and full
|
||||
if ('state' in flatAlert) {
|
||||
lines.push(`- **State:** ${flatAlert.state}`);
|
||||
}
|
||||
|
||||
// Reference only in full
|
||||
if ('ref' in flatAlert) {
|
||||
lines.push(`- **Reference:** ${flatAlert.ref}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
78
src/formatters/sarif.ts
Normal file
78
src/formatters/sarif.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { SarifBuilder, SarifResultBuilder, SarifRunBuilder } from 'node-sarif-builder';
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import {
|
||||
type DetailLevel,
|
||||
type FullAlert,
|
||||
filterAlertByDetail,
|
||||
type MediumAlert,
|
||||
type MinimumAlert,
|
||||
} from '../lib/types.js';
|
||||
|
||||
/**
|
||||
* Format alerts as SARIF (Static Analysis Results Interchange Format)
|
||||
*/
|
||||
export function formatAsSARIF(
|
||||
alerts: CodeQLAlert[],
|
||||
_repoName: string,
|
||||
detailLevel: DetailLevel = 'medium',
|
||||
): string {
|
||||
// For raw format, return alerts as JSON (SARIF doesn't make sense for raw)
|
||||
if (detailLevel === 'raw') {
|
||||
return JSON.stringify(alerts, null, 2);
|
||||
}
|
||||
|
||||
const sarifBuilder = new SarifBuilder();
|
||||
|
||||
// Tool version only available in full mode
|
||||
let toolVersion = '1.0.0';
|
||||
if (detailLevel === 'full' && alerts.length > 0) {
|
||||
const fullAlert = filterAlertByDetail(alerts[0], 'full');
|
||||
if ('tool_version' in fullAlert) {
|
||||
toolVersion = fullAlert.tool_version;
|
||||
}
|
||||
}
|
||||
|
||||
const runBuilder = new SarifRunBuilder().initSimple({
|
||||
toolDriverName: 'CodeQL',
|
||||
toolDriverVersion: toolVersion,
|
||||
});
|
||||
|
||||
for (const alert of alerts) {
|
||||
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||
// Type assertion: we know filtered is a flattened alert type (not raw, checked above)
|
||||
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||
const result = new SarifResultBuilder();
|
||||
|
||||
// SARIF requires certain minimum fields
|
||||
// For minimum level, we use line numbers but set column to 1 if not available
|
||||
const startColumn = 'start_column' in flatAlert ? flatAlert.start_column : 1;
|
||||
|
||||
result.initSimple({
|
||||
ruleId: flatAlert.rule_id,
|
||||
level: mapSeverityToLevel(flatAlert.severity),
|
||||
messageText: flatAlert.message,
|
||||
fileUri: flatAlert.file_path,
|
||||
startLine: flatAlert.start_line,
|
||||
startColumn,
|
||||
});
|
||||
|
||||
runBuilder.addResult(result);
|
||||
}
|
||||
|
||||
sarifBuilder.addRun(runBuilder);
|
||||
// buildSarifJsonString returns a JSON string
|
||||
return sarifBuilder.buildSarifJsonString();
|
||||
}
|
||||
|
||||
function mapSeverityToLevel(severity: string): 'error' | 'warning' | 'note' {
|
||||
switch (severity.toLowerCase()) {
|
||||
case 'error':
|
||||
case 'critical':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'note';
|
||||
}
|
||||
}
|
||||
71
src/formatters/text.ts
Normal file
71
src/formatters/text.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||
import {
|
||||
type DetailLevel,
|
||||
type FullAlert,
|
||||
filterAlertByDetail,
|
||||
type MediumAlert,
|
||||
type MinimumAlert,
|
||||
} from '../lib/types.js';
|
||||
|
||||
/**
|
||||
* Format alerts as plain text
|
||||
*/
|
||||
export function formatAsText(alerts: CodeQLAlert[], detailLevel: DetailLevel = 'medium'): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`CodeQL Security Scan Report`);
|
||||
lines.push(`Total Alerts: ${alerts.length}`);
|
||||
lines.push(`Detail Level: ${detailLevel}`);
|
||||
lines.push(`${'='.repeat(80)}\n`);
|
||||
|
||||
for (const alert of alerts) {
|
||||
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||
|
||||
// Handle raw format - return original JSON-like structure
|
||||
if (detailLevel === 'raw') {
|
||||
lines.push(JSON.stringify(filtered, null, 2));
|
||||
lines.push(`${'-'.repeat(80)}\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type assertion: after raw check, we know filtered is a flattened alert type
|
||||
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||
|
||||
lines.push(`Alert #${flatAlert.number}`);
|
||||
lines.push(`Rule: ${flatAlert.rule_id}`);
|
||||
lines.push(`Name: ${flatAlert.rule_name}`);
|
||||
lines.push(`Severity: ${flatAlert.severity}`);
|
||||
|
||||
// Description only in medium and full
|
||||
if ('rule_description' in flatAlert) {
|
||||
lines.push(`Description: ${flatAlert.rule_description}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Location:');
|
||||
lines.push(` File: ${flatAlert.file_path}`);
|
||||
lines.push(` Lines: ${flatAlert.start_line}-${flatAlert.end_line}`);
|
||||
|
||||
// Columns only in medium and full
|
||||
if ('start_column' in flatAlert) {
|
||||
lines.push(` Columns: ${flatAlert.start_column}-${flatAlert.end_column}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Message:');
|
||||
lines.push(` ${flatAlert.message}`);
|
||||
|
||||
// Commit is now in all levels
|
||||
lines.push('');
|
||||
lines.push(`Commit: ${flatAlert.commit_sha}`);
|
||||
|
||||
// State only in medium and full
|
||||
if ('state' in flatAlert) {
|
||||
lines.push(`State: ${flatAlert.state}`);
|
||||
}
|
||||
|
||||
lines.push(`${'-'.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
26
src/lib/auth.ts
Normal file
26
src/lib/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Get GitHub token from GITHUB_TOKEN env var, or fall back to gh CLI
|
||||
*/
|
||||
export function getGitHubToken(): string {
|
||||
// First, try GITHUB_TOKEN environment variable
|
||||
const envToken = process.env.GITHUB_TOKEN;
|
||||
if (envToken) {
|
||||
return envToken;
|
||||
}
|
||||
|
||||
// Fall back to gh CLI
|
||||
try {
|
||||
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
} catch (_error) {
|
||||
// gh CLI not available or not authenticated
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'GitHub token not found. Please set GITHUB_TOKEN environment variable or authenticate with `gh auth login`',
|
||||
);
|
||||
}
|
||||
108
src/lib/codeql.ts
Normal file
108
src/lib/codeql.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Octokit } from 'octokit';
|
||||
import type { GitHubRepo } from './git.js';
|
||||
|
||||
export interface CodeQLAlert {
|
||||
number: number;
|
||||
rule: {
|
||||
id: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
name: string;
|
||||
};
|
||||
most_recent_instance: {
|
||||
ref: string;
|
||||
analysis_key: string;
|
||||
category: string;
|
||||
state: string;
|
||||
commit_sha: string;
|
||||
message: {
|
||||
text: string;
|
||||
};
|
||||
location: {
|
||||
path: string;
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
start_column: number;
|
||||
end_column: number;
|
||||
};
|
||||
};
|
||||
help?: string;
|
||||
tool: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all open CodeQL alerts for a repository with pagination
|
||||
*/
|
||||
export async function fetchCodeQLAlerts(
|
||||
octokit: Octokit,
|
||||
repo: GitHubRepo,
|
||||
): Promise<CodeQLAlert[]> {
|
||||
const alerts: CodeQLAlert[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const response = await octokit.rest.codeScanning.listAlertsForRepo({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state: 'open',
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
|
||||
if (response.data.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Collect alert numbers for detailed fetch
|
||||
for (const alert of response.data) {
|
||||
alerts.push(alert as CodeQLAlert);
|
||||
}
|
||||
|
||||
// If we got fewer than perPage results, we're done
|
||||
if (response.data.length < perPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detailed information for a specific alert
|
||||
*/
|
||||
export async function fetchAlertDetails(
|
||||
octokit: Octokit,
|
||||
repo: GitHubRepo,
|
||||
alertNumber: number,
|
||||
): Promise<CodeQLAlert> {
|
||||
const response = await octokit.rest.codeScanning.getAlert({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
alert_number: alertNumber,
|
||||
});
|
||||
|
||||
return response.data as CodeQLAlert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all alerts with full details
|
||||
*/
|
||||
export async function fetchAllAlertsWithDetails(
|
||||
octokit: Octokit,
|
||||
repo: GitHubRepo,
|
||||
): Promise<CodeQLAlert[]> {
|
||||
const alerts = await fetchCodeQLAlerts(octokit, repo);
|
||||
|
||||
// Fetch details for each alert
|
||||
const detailedAlerts = await Promise.all(
|
||||
alerts.map((alert) => fetchAlertDetails(octokit, repo, alert.number)),
|
||||
);
|
||||
|
||||
return detailedAlerts;
|
||||
}
|
||||
67
src/lib/git.ts
Normal file
67
src/lib/git.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import simpleGit from 'simple-git';
|
||||
|
||||
export interface GitHubRepo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract GitHub owner and repository name from git remote URL
|
||||
*/
|
||||
export function parseGitHubUrl(url: string): GitHubRepo | null {
|
||||
// Match various GitHub URL formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
// - git@github.com:owner/repo.git
|
||||
// - https://github.com/owner/repo
|
||||
// - git://github.com/owner/repo.git
|
||||
const patterns = [/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/, /^([^/]+)\/([^/]+)(\.git)?$/];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
owner: match[1],
|
||||
repo: match[2].replace(/\.git$/, ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub owner and repo from current directory's git remote
|
||||
*/
|
||||
export async function getGitHubRepoFromRemote(cwd?: string): Promise<GitHubRepo> {
|
||||
const git = simpleGit(cwd);
|
||||
|
||||
try {
|
||||
const remotes = await git.getRemotes(true);
|
||||
|
||||
if (remotes.length === 0) {
|
||||
throw new Error('No git remotes found. Make sure you are in a git repository.');
|
||||
}
|
||||
|
||||
// Try origin first, then fall back to the first remote
|
||||
const originRemote = remotes.find((r) => r.name === 'origin');
|
||||
const remote = originRemote || remotes[0];
|
||||
|
||||
if (!remote.refs.fetch && !remote.refs.push) {
|
||||
throw new Error('No valid remote URL found.');
|
||||
}
|
||||
|
||||
const remoteUrl = remote.refs.fetch || remote.refs.push;
|
||||
const repoInfo = parseGitHubUrl(remoteUrl);
|
||||
|
||||
if (!repoInfo) {
|
||||
throw new Error(`Unable to parse GitHub repository from remote URL: ${remoteUrl}`);
|
||||
}
|
||||
|
||||
return repoInfo;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to get git remote information.');
|
||||
}
|
||||
}
|
||||
117
src/lib/types.ts
Normal file
117
src/lib/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { CodeQLAlert } from './codeql.js';
|
||||
|
||||
export type DetailLevel = 'minimum' | 'medium' | 'full' | 'raw';
|
||||
|
||||
/**
|
||||
* Flattened alert structure with minimum essential fields
|
||||
* All levels include commit_sha for LLM context
|
||||
*/
|
||||
export interface MinimumAlert {
|
||||
number: number;
|
||||
rule_id: string;
|
||||
rule_name: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
file_path: string;
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
commit_sha: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Medium detail level adds helpful context fields
|
||||
*/
|
||||
export interface MediumAlert extends MinimumAlert {
|
||||
rule_description: string;
|
||||
start_column: number;
|
||||
end_column: number;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full detail level includes all available metadata
|
||||
*/
|
||||
export interface FullAlert extends MediumAlert {
|
||||
ref: string;
|
||||
analysis_key: string;
|
||||
category: string;
|
||||
tool_name: string;
|
||||
tool_version: string;
|
||||
help_text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alert data based on detail level
|
||||
* Returns flattened structure to reduce tokens, or raw CodeQLAlert for 'raw' level
|
||||
*/
|
||||
export function filterAlertByDetail(
|
||||
alert: CodeQLAlert,
|
||||
level: DetailLevel,
|
||||
): MinimumAlert | MediumAlert | FullAlert | CodeQLAlert {
|
||||
if (level === 'raw') {
|
||||
return alert;
|
||||
}
|
||||
|
||||
if (level === 'full') {
|
||||
const fullAlert: FullAlert = {
|
||||
number: alert.number,
|
||||
rule_id: alert.rule.id,
|
||||
rule_name: alert.rule.name,
|
||||
severity: alert.rule.severity,
|
||||
message: alert.most_recent_instance.message.text,
|
||||
file_path: alert.most_recent_instance.location.path,
|
||||
start_line: alert.most_recent_instance.location.start_line,
|
||||
end_line: alert.most_recent_instance.location.end_line,
|
||||
commit_sha: alert.most_recent_instance.commit_sha,
|
||||
rule_description: alert.rule.description,
|
||||
start_column: alert.most_recent_instance.location.start_column,
|
||||
end_column: alert.most_recent_instance.location.end_column,
|
||||
state: alert.most_recent_instance.state,
|
||||
ref: alert.most_recent_instance.ref,
|
||||
analysis_key: alert.most_recent_instance.analysis_key,
|
||||
category: alert.most_recent_instance.category,
|
||||
tool_name: alert.tool.name,
|
||||
tool_version: alert.tool.version,
|
||||
};
|
||||
|
||||
// Add help_text if available
|
||||
if (alert.help) {
|
||||
fullAlert.help_text = alert.help;
|
||||
}
|
||||
|
||||
return fullAlert;
|
||||
}
|
||||
|
||||
if (level === 'medium') {
|
||||
const mediumAlert: MediumAlert = {
|
||||
number: alert.number,
|
||||
rule_id: alert.rule.id,
|
||||
rule_name: alert.rule.name,
|
||||
severity: alert.rule.severity,
|
||||
message: alert.most_recent_instance.message.text,
|
||||
file_path: alert.most_recent_instance.location.path,
|
||||
start_line: alert.most_recent_instance.location.start_line,
|
||||
end_line: alert.most_recent_instance.location.end_line,
|
||||
commit_sha: alert.most_recent_instance.commit_sha,
|
||||
rule_description: alert.rule.description,
|
||||
start_column: alert.most_recent_instance.location.start_column,
|
||||
end_column: alert.most_recent_instance.location.end_column,
|
||||
state: alert.most_recent_instance.state,
|
||||
};
|
||||
return mediumAlert;
|
||||
}
|
||||
|
||||
// minimum level
|
||||
const minimumAlert: MinimumAlert = {
|
||||
number: alert.number,
|
||||
rule_id: alert.rule.id,
|
||||
rule_name: alert.rule.name,
|
||||
severity: alert.rule.severity,
|
||||
message: alert.most_recent_instance.message.text,
|
||||
file_path: alert.most_recent_instance.location.path,
|
||||
start_line: alert.most_recent_instance.location.start_line,
|
||||
end_line: alert.most_recent_instance.location.end_line,
|
||||
commit_sha: alert.most_recent_instance.commit_sha,
|
||||
};
|
||||
return minimumAlert;
|
||||
}
|
||||
Reference in New Issue
Block a user