Initial commit

This commit is contained in:
2025-09-30 22:34:56 +03:00
commit fafd5e89d4
28 changed files with 6481 additions and 0 deletions

View 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
View 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)...');
});
});
});

View 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();
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}