@dependabit/github-client 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/dist/auth/basic.d.ts +46 -0
  5. package/dist/auth/basic.d.ts.map +1 -0
  6. package/dist/auth/basic.js +88 -0
  7. package/dist/auth/basic.js.map +1 -0
  8. package/dist/auth/oauth.d.ts +48 -0
  9. package/dist/auth/oauth.d.ts.map +1 -0
  10. package/dist/auth/oauth.js +139 -0
  11. package/dist/auth/oauth.js.map +1 -0
  12. package/dist/auth/token.d.ts +40 -0
  13. package/dist/auth/token.d.ts.map +1 -0
  14. package/dist/auth/token.js +67 -0
  15. package/dist/auth/token.js.map +1 -0
  16. package/dist/auth.d.ts +47 -0
  17. package/dist/auth.d.ts.map +1 -0
  18. package/dist/auth.js +78 -0
  19. package/dist/auth.js.map +1 -0
  20. package/dist/client.d.ts +53 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +74 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/commits.d.ts +57 -0
  25. package/dist/commits.d.ts.map +1 -0
  26. package/dist/commits.js +113 -0
  27. package/dist/commits.js.map +1 -0
  28. package/dist/feedback.d.ts +69 -0
  29. package/dist/feedback.d.ts.map +1 -0
  30. package/dist/feedback.js +111 -0
  31. package/dist/feedback.js.map +1 -0
  32. package/dist/index.d.ts +15 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/issues.d.ts +55 -0
  37. package/dist/issues.d.ts.map +1 -0
  38. package/dist/issues.js +123 -0
  39. package/dist/issues.js.map +1 -0
  40. package/dist/rate-limit.d.ts +71 -0
  41. package/dist/rate-limit.d.ts.map +1 -0
  42. package/dist/rate-limit.js +145 -0
  43. package/dist/rate-limit.js.map +1 -0
  44. package/dist/releases.d.ts +50 -0
  45. package/dist/releases.d.ts.map +1 -0
  46. package/dist/releases.js +113 -0
  47. package/dist/releases.js.map +1 -0
  48. package/package.json +39 -0
  49. package/src/auth/basic.ts +102 -0
  50. package/src/auth/oauth.ts +183 -0
  51. package/src/auth/token.ts +81 -0
  52. package/src/auth.ts +100 -0
  53. package/src/client.test.ts +115 -0
  54. package/src/client.ts +109 -0
  55. package/src/commits.ts +184 -0
  56. package/src/feedback.ts +166 -0
  57. package/src/index.ts +15 -0
  58. package/src/issues.ts +185 -0
  59. package/src/rate-limit.ts +210 -0
  60. package/src/releases.ts +149 -0
  61. package/test/auth/basic.test.ts +122 -0
  62. package/test/auth/oauth.test.ts +196 -0
  63. package/test/auth/token.test.ts +97 -0
  64. package/test/commits.test.ts +169 -0
  65. package/test/feedback.test.ts +203 -0
  66. package/test/issues.test.ts +197 -0
  67. package/test/rate-limit.test.ts +154 -0
  68. package/test/releases.test.ts +187 -0
  69. package/tsconfig.json +10 -0
  70. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { OAuthHandler } from '../../src/auth/oauth';
3
+
4
+ describe('OAuthHandler', () => {
5
+ beforeEach(() => {
6
+ vi.clearAllMocks();
7
+ });
8
+
9
+ describe('constructor', () => {
10
+ it('should create handler with client credentials', () => {
11
+ const config = {
12
+ clientId: 'test_client_id',
13
+ clientSecret: 'test_client_secret',
14
+ redirectUri: 'http://localhost:3000/callback'
15
+ };
16
+ const handler = new OAuthHandler(config);
17
+ expect(handler).toBeInstanceOf(OAuthHandler);
18
+ });
19
+
20
+ it('should throw error for missing clientId', () => {
21
+ const config = {
22
+ clientId: '',
23
+ clientSecret: 'secret',
24
+ redirectUri: 'http://localhost:3000/callback'
25
+ };
26
+ expect(() => new OAuthHandler(config)).toThrow('clientId is required');
27
+ });
28
+
29
+ it('should throw error for missing clientSecret', () => {
30
+ const config = {
31
+ clientId: 'client',
32
+ clientSecret: '',
33
+ redirectUri: 'http://localhost:3000/callback'
34
+ };
35
+ expect(() => new OAuthHandler(config)).toThrow('clientSecret is required');
36
+ });
37
+ });
38
+
39
+ describe('authenticate', () => {
40
+ it('should exchange code for access token', async () => {
41
+ const config = {
42
+ clientId: 'test_client',
43
+ clientSecret: 'test_secret',
44
+ redirectUri: 'http://localhost:3000/callback'
45
+ };
46
+ const handler = new OAuthHandler(config);
47
+
48
+ // Mock the token exchange
49
+ vi.spyOn(handler as any, 'exchangeCodeForToken').mockResolvedValue({
50
+ access_token: 'gho_accesstoken123',
51
+ token_type: 'bearer',
52
+ scope: 'repo'
53
+ });
54
+
55
+ const auth = await handler.authenticate('test_code');
56
+
57
+ expect(auth).toEqual({
58
+ type: 'oauth',
59
+ token: 'gho_accesstoken123',
60
+ tokenType: 'bearer',
61
+ scope: 'repo'
62
+ });
63
+ });
64
+
65
+ it('should throw error for invalid code', async () => {
66
+ const config = {
67
+ clientId: 'test_client',
68
+ clientSecret: 'test_secret',
69
+ redirectUri: 'http://localhost:3000/callback'
70
+ };
71
+ const handler = new OAuthHandler(config);
72
+
73
+ await expect(handler.authenticate('')).rejects.toThrow('Authorization code is required');
74
+ });
75
+
76
+ it('should handle token exchange failure', async () => {
77
+ const config = {
78
+ clientId: 'test_client',
79
+ clientSecret: 'test_secret',
80
+ redirectUri: 'http://localhost:3000/callback'
81
+ };
82
+ const handler = new OAuthHandler(config);
83
+
84
+ vi.spyOn(handler as any, 'exchangeCodeForToken').mockRejectedValue(
85
+ new Error('Invalid authorization code')
86
+ );
87
+
88
+ await expect(handler.authenticate('bad_code')).rejects.toThrow('Invalid authorization code');
89
+ });
90
+ });
91
+
92
+ describe('getAuthorizationUrl', () => {
93
+ it('should generate authorization URL with scopes', () => {
94
+ const config = {
95
+ clientId: 'test_client',
96
+ clientSecret: 'test_secret',
97
+ redirectUri: 'http://localhost:3000/callback'
98
+ };
99
+ const handler = new OAuthHandler(config);
100
+
101
+ const url = handler.getAuthorizationUrl(['repo', 'user']);
102
+
103
+ expect(url).toContain('https://github.com/login/oauth/authorize');
104
+ expect(url).toContain('client_id=test_client');
105
+ expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback');
106
+ expect(url).toContain('scope=repo+user'); // URLSearchParams uses + for spaces
107
+ });
108
+
109
+ it('should include state parameter for CSRF protection', () => {
110
+ const config = {
111
+ clientId: 'test_client',
112
+ clientSecret: 'test_secret',
113
+ redirectUri: 'http://localhost:3000/callback'
114
+ };
115
+ const handler = new OAuthHandler(config);
116
+
117
+ const url = handler.getAuthorizationUrl(['repo'], 'random_state_123');
118
+
119
+ expect(url).toContain('state=random_state_123');
120
+ });
121
+ });
122
+
123
+ describe('refreshToken', () => {
124
+ it('should refresh expired access token', async () => {
125
+ const config = {
126
+ clientId: 'test_client',
127
+ clientSecret: 'test_secret',
128
+ redirectUri: 'http://localhost:3000/callback'
129
+ };
130
+ const handler = new OAuthHandler(config);
131
+
132
+ vi.spyOn(handler as any, 'performTokenRefresh').mockResolvedValue({
133
+ access_token: 'gho_newtoken456',
134
+ token_type: 'bearer',
135
+ scope: 'repo'
136
+ });
137
+
138
+ const result = await handler.refreshToken('refresh_token_123');
139
+
140
+ expect(result).toEqual({
141
+ type: 'oauth',
142
+ token: 'gho_newtoken456',
143
+ tokenType: 'bearer',
144
+ scope: 'repo'
145
+ });
146
+ });
147
+
148
+ it('should throw error for missing refresh token', async () => {
149
+ const config = {
150
+ clientId: 'test_client',
151
+ clientSecret: 'test_secret',
152
+ redirectUri: 'http://localhost:3000/callback'
153
+ };
154
+ const handler = new OAuthHandler(config);
155
+
156
+ await expect(handler.refreshToken('')).rejects.toThrow('Refresh token is required');
157
+ });
158
+ });
159
+
160
+ describe('validate', () => {
161
+ it('should validate OAuth configuration', () => {
162
+ const config = {
163
+ clientId: 'test_client',
164
+ clientSecret: 'test_secret',
165
+ redirectUri: 'http://localhost:3000/callback'
166
+ };
167
+ const handler = new OAuthHandler(config);
168
+
169
+ expect(handler.validate()).toBe(true);
170
+ });
171
+
172
+ it('should fail validation for invalid redirect URI', () => {
173
+ const config = {
174
+ clientId: 'test_client',
175
+ clientSecret: 'test_secret',
176
+ redirectUri: 'invalid-uri'
177
+ };
178
+ const handler = new OAuthHandler(config);
179
+
180
+ expect(handler.validate()).toBe(false);
181
+ });
182
+ });
183
+
184
+ describe('getType', () => {
185
+ it('should return oauth type', () => {
186
+ const config = {
187
+ clientId: 'test_client',
188
+ clientSecret: 'test_secret',
189
+ redirectUri: 'http://localhost:3000/callback'
190
+ };
191
+ const handler = new OAuthHandler(config);
192
+
193
+ expect(handler.getType()).toBe('oauth');
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { TokenAuthHandler } from '../../src/auth/token';
3
+
4
+ describe('TokenAuthHandler', () => {
5
+ beforeEach(() => {
6
+ vi.clearAllMocks();
7
+ });
8
+
9
+ describe('constructor', () => {
10
+ it('should create handler with GitHub PAT token', () => {
11
+ const token = 'ghp_testtoken123';
12
+ const handler = new TokenAuthHandler(token);
13
+ expect(handler).toBeInstanceOf(TokenAuthHandler);
14
+ });
15
+
16
+ it('should create handler with API key', () => {
17
+ const token = 'api_key_12345';
18
+ const handler = new TokenAuthHandler(token);
19
+ expect(handler).toBeInstanceOf(TokenAuthHandler);
20
+ });
21
+
22
+ it('should throw error for empty token', () => {
23
+ expect(() => new TokenAuthHandler('')).toThrow('Token cannot be empty');
24
+ });
25
+ });
26
+
27
+ describe('authenticate', () => {
28
+ it('should return auth object with token', async () => {
29
+ const token = 'ghp_testtoken123';
30
+ const handler = new TokenAuthHandler(token);
31
+ const auth = await handler.authenticate();
32
+
33
+ expect(auth).toEqual({
34
+ type: 'token',
35
+ token: token
36
+ });
37
+ });
38
+
39
+ it('should validate token format for GitHub PAT', async () => {
40
+ const token = 'ghp_validtoken123456';
41
+ const handler = new TokenAuthHandler(token);
42
+ const auth = await handler.authenticate();
43
+
44
+ expect(auth.token).toBe(token);
45
+ });
46
+
47
+ it('should accept fine-grained tokens', async () => {
48
+ const token = 'github_pat_validtoken123456';
49
+ const handler = new TokenAuthHandler(token);
50
+ const auth = await handler.authenticate();
51
+
52
+ expect(auth.token).toBe(token);
53
+ });
54
+ });
55
+
56
+ describe('validate', () => {
57
+ it('should validate token format', () => {
58
+ const handler = new TokenAuthHandler('ghp_test123');
59
+ expect(handler.validate()).toBe(true);
60
+ });
61
+
62
+ it('should accept any non-empty token', () => {
63
+ // Token validation is lenient - allows API keys without GitHub prefix
64
+ const handler = new TokenAuthHandler('api_key_123');
65
+ expect(handler.validate()).toBe(true);
66
+ });
67
+
68
+ it('should accept various GitHub token prefixes', () => {
69
+ const validPrefixes = ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_'];
70
+
71
+ validPrefixes.forEach((prefix) => {
72
+ const handler = new TokenAuthHandler(`${prefix}test123`);
73
+ expect(handler.validate()).toBe(true);
74
+ });
75
+ });
76
+ });
77
+
78
+ describe('getType', () => {
79
+ it('should return token type', () => {
80
+ const handler = new TokenAuthHandler('ghp_test');
81
+ expect(handler.getType()).toBe('token');
82
+ });
83
+ });
84
+
85
+ describe('token rotation', () => {
86
+ it('should allow token update', () => {
87
+ const handler = new TokenAuthHandler('ghp_old');
88
+ handler.updateToken('ghp_new');
89
+ expect(handler.getToken()).toBe('ghp_new');
90
+ });
91
+
92
+ it('should throw error on empty token update', () => {
93
+ const handler = new TokenAuthHandler('ghp_test');
94
+ expect(() => handler.updateToken('')).toThrow('Token cannot be empty');
95
+ });
96
+ });
97
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { fetchCommits, getCommitDiff, parseCommitFiles } from '../src/commits.js';
3
+
4
+ // Mock the octokit module
5
+ const mockListCommits = vi.fn();
6
+ const mockGetCommit = vi.fn();
7
+
8
+ vi.mock('octokit', () => {
9
+ class MockOctokit {
10
+ rest = {
11
+ repos: {
12
+ listCommits: mockListCommits,
13
+ getCommit: mockGetCommit
14
+ }
15
+ };
16
+ }
17
+
18
+ return {
19
+ Octokit: MockOctokit
20
+ };
21
+ });
22
+
23
+ describe('Commit Analysis Tests', () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ describe('fetchCommits', () => {
29
+ it('should fetch commits from GitHub API', async () => {
30
+ const mockCommits = [
31
+ {
32
+ sha: 'abc123',
33
+ commit: {
34
+ message: 'feat: add new dependency',
35
+ author: { name: 'Test User', date: '2024-01-01T00:00:00Z' }
36
+ }
37
+ },
38
+ {
39
+ sha: 'def456',
40
+ commit: {
41
+ message: 'fix: update README',
42
+ author: { name: 'Test User', date: '2024-01-01T01:00:00Z' }
43
+ }
44
+ }
45
+ ];
46
+
47
+ mockListCommits.mockResolvedValue({
48
+ data: mockCommits
49
+ });
50
+
51
+ const client = {
52
+ getOctokit: () => ({ rest: { repos: { listCommits: mockListCommits } } })
53
+ } as any;
54
+ const result = await fetchCommits(client, 'owner', 'repo', { since: '2024-01-01T00:00:00Z' });
55
+
56
+ expect(result).toHaveLength(2);
57
+ expect(result[0].sha).toBe('abc123');
58
+ expect(result[1].sha).toBe('def456');
59
+ expect(mockListCommits).toHaveBeenCalledWith({
60
+ owner: 'owner',
61
+ repo: 'repo',
62
+ since: '2024-01-01T00:00:00Z'
63
+ });
64
+ });
65
+
66
+ it('should handle empty commit list', async () => {
67
+ mockListCommits.mockResolvedValue({ data: [] });
68
+
69
+ const client = {
70
+ getOctokit: () => ({ rest: { repos: { listCommits: mockListCommits } } })
71
+ } as any;
72
+ const result = await fetchCommits(client, 'owner', 'repo');
73
+
74
+ expect(result).toHaveLength(0);
75
+ });
76
+ });
77
+
78
+ describe('getCommitDiff', () => {
79
+ it('should fetch commit diff with file changes', async () => {
80
+ const mockCommitData = {
81
+ sha: 'abc123',
82
+ files: [
83
+ {
84
+ filename: 'README.md',
85
+ status: 'modified',
86
+ additions: 5,
87
+ deletions: 2,
88
+ changes: 7,
89
+ patch: '@@ -1,3 +1,6 @@\n-Old line\n+New line\n+Another line'
90
+ },
91
+ {
92
+ filename: 'src/index.ts',
93
+ status: 'added',
94
+ additions: 10,
95
+ deletions: 0,
96
+ changes: 10,
97
+ patch: '@@ -0,0 +1,10 @@\n+export function test() {}'
98
+ }
99
+ ]
100
+ };
101
+
102
+ mockGetCommit.mockResolvedValue({ data: mockCommitData });
103
+
104
+ const client = {
105
+ getOctokit: () => ({ rest: { repos: { getCommit: mockGetCommit } } })
106
+ } as any;
107
+ const result = await getCommitDiff(client, 'owner', 'repo', 'abc123');
108
+
109
+ expect(result.sha).toBe('abc123');
110
+ expect(result.files).toHaveLength(2);
111
+ expect(result.files[0].filename).toBe('README.md');
112
+ expect(result.files[0].status).toBe('modified');
113
+ expect(result.files[1].filename).toBe('src/index.ts');
114
+ expect(result.files[1].status).toBe('added');
115
+ });
116
+
117
+ it('should handle commit without file changes', async () => {
118
+ mockGetCommit.mockResolvedValue({
119
+ data: { sha: 'abc123', files: [] }
120
+ });
121
+
122
+ const client = {
123
+ getOctokit: () => ({ rest: { repos: { getCommit: mockGetCommit } } })
124
+ } as any;
125
+ const result = await getCommitDiff(client, 'owner', 'repo', 'abc123');
126
+
127
+ expect(result.files).toHaveLength(0);
128
+ });
129
+ });
130
+
131
+ describe('parseCommitFiles', () => {
132
+ it('should extract changed files from commit', () => {
133
+ const files = [
134
+ { filename: 'README.md', status: 'modified' as const, patch: '...' },
135
+ { filename: 'src/index.ts', status: 'added' as const, patch: '...' },
136
+ { filename: 'docs/guide.md', status: 'modified' as const, patch: '...' },
137
+ { filename: 'test.ts', status: 'removed' as const }
138
+ ];
139
+
140
+ const result = parseCommitFiles(files);
141
+
142
+ expect(result.modified).toContain('README.md');
143
+ expect(result.modified).toContain('docs/guide.md');
144
+ expect(result.added).toContain('src/index.ts');
145
+ expect(result.removed).toContain('test.ts');
146
+ });
147
+
148
+ it('should handle empty file list', () => {
149
+ const result = parseCommitFiles([]);
150
+
151
+ expect(result.added).toHaveLength(0);
152
+ expect(result.modified).toHaveLength(0);
153
+ expect(result.removed).toHaveLength(0);
154
+ });
155
+
156
+ it('should group files by type of change', () => {
157
+ const files = [
158
+ { filename: 'file1.md', status: 'added' as const, patch: '...' },
159
+ { filename: 'file2.md', status: 'added' as const, patch: '...' },
160
+ { filename: 'file3.md', status: 'modified' as const, patch: '...' }
161
+ ];
162
+
163
+ const result = parseCommitFiles(files);
164
+
165
+ expect(result.added).toHaveLength(2);
166
+ expect(result.modified).toHaveLength(1);
167
+ });
168
+ });
169
+ });
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { FeedbackListener } from '../src/feedback.js';
3
+ import type { IssueManagerInterface } from '../src/feedback.js';
4
+
5
+ describe('FeedbackListener', () => {
6
+ let mockIssueManager: IssueManagerInterface;
7
+
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+
11
+ // Mock IssueManager
12
+ mockIssueManager = {
13
+ listIssues: vi.fn(),
14
+ getIssue: vi.fn()
15
+ } as IssueManagerInterface;
16
+ });
17
+
18
+ describe('constructor', () => {
19
+ it('should create listener with issue manager', () => {
20
+ const listener = new FeedbackListener(mockIssueManager);
21
+ expect(listener).toBeInstanceOf(FeedbackListener);
22
+ });
23
+
24
+ it('should accept custom labels', () => {
25
+ const config = {
26
+ truePositiveLabel: 'confirmed',
27
+ falsePositiveLabel: 'not-a-bug'
28
+ };
29
+ const listener = new FeedbackListener(mockIssueManager, config);
30
+ expect(listener).toBeInstanceOf(FeedbackListener);
31
+ });
32
+ });
33
+
34
+ describe('collectFeedback', () => {
35
+ it('should collect issues with feedback labels', async () => {
36
+ const mockIssues = [
37
+ { number: 1, labels: ['false-positive'], title: 'Test 1', created_at: '2026-01-01' },
38
+ { number: 2, labels: ['true-positive'], title: 'Test 2', created_at: '2026-01-02' },
39
+ { number: 3, labels: ['false-positive'], title: 'Test 3', created_at: '2026-01-03' }
40
+ ];
41
+
42
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue(mockIssues as any);
43
+
44
+ const listener = new FeedbackListener(mockIssueManager);
45
+ const feedback = await listener.collectFeedback();
46
+
47
+ expect(feedback.truePositives).toHaveLength(1);
48
+ expect(feedback.falsePositives).toHaveLength(2);
49
+ expect(feedback.total).toBe(3);
50
+ });
51
+
52
+ it('should filter by date range', async () => {
53
+ const mockIssues = [
54
+ {
55
+ number: 1,
56
+ labels: ['false-positive'],
57
+ title: 'Old issue',
58
+ created_at: '2025-12-01T00:00:00Z'
59
+ },
60
+ {
61
+ number: 2,
62
+ labels: ['false-positive'],
63
+ title: 'Recent issue',
64
+ created_at: '2026-01-25T00:00:00Z'
65
+ }
66
+ ];
67
+
68
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue(mockIssues as any);
69
+
70
+ const listener = new FeedbackListener(mockIssueManager);
71
+ const startDate = new Date('2026-01-01');
72
+ const feedback = await listener.collectFeedback({ startDate });
73
+
74
+ expect(feedback.falsePositives).toHaveLength(1);
75
+ expect(feedback.falsePositives[0].number).toBe(2);
76
+ });
77
+
78
+ it('should handle empty results', async () => {
79
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue([]);
80
+
81
+ const listener = new FeedbackListener(mockIssueManager);
82
+ const feedback = await listener.collectFeedback();
83
+
84
+ expect(feedback.truePositives).toHaveLength(0);
85
+ expect(feedback.falsePositives).toHaveLength(0);
86
+ expect(feedback.total).toBe(0);
87
+ });
88
+
89
+ it('should filter by repository', async () => {
90
+ const mockIssues = [
91
+ { number: 1, labels: ['false-positive'], title: 'Test', repository: 'owner/repo1' },
92
+ { number: 2, labels: ['false-positive'], title: 'Test', repository: 'owner/repo2' }
93
+ ];
94
+
95
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue(mockIssues as any);
96
+
97
+ const listener = new FeedbackListener(mockIssueManager);
98
+ const feedback = await listener.collectFeedback({ repository: 'owner/repo1' });
99
+
100
+ expect(feedback.falsePositives).toHaveLength(1);
101
+ });
102
+ });
103
+
104
+ describe('getFeedbackRate', () => {
105
+ it('should calculate false positive rate', async () => {
106
+ const mockIssues = [
107
+ { number: 1, labels: ['false-positive'], title: 'FP1' },
108
+ { number: 2, labels: ['true-positive'], title: 'TP1' },
109
+ { number: 3, labels: ['true-positive'], title: 'TP2' },
110
+ { number: 4, labels: ['false-positive'], title: 'FP2' }
111
+ ];
112
+
113
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue(mockIssues as any);
114
+
115
+ const listener = new FeedbackListener(mockIssueManager);
116
+ const rate = await listener.getFeedbackRate();
117
+
118
+ expect(rate.falsePositiveRate).toBe(0.5); // 2 FP out of 4 total
119
+ expect(rate.truePositiveRate).toBe(0.5); // 2 TP out of 4 total
120
+ expect(rate.totalFeedback).toBe(4);
121
+ });
122
+
123
+ it('should return 0 for empty feedback', async () => {
124
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue([]);
125
+
126
+ const listener = new FeedbackListener(mockIssueManager);
127
+ const rate = await listener.getFeedbackRate();
128
+
129
+ expect(rate.falsePositiveRate).toBe(0);
130
+ expect(rate.truePositiveRate).toBe(0);
131
+ expect(rate.totalFeedback).toBe(0);
132
+ });
133
+ });
134
+
135
+ describe('getRecentFeedback', () => {
136
+ it('should get feedback from last 30 days', async () => {
137
+ const now = new Date('2026-01-31');
138
+ const mockIssues = [
139
+ {
140
+ number: 1,
141
+ labels: ['false-positive'],
142
+ title: 'Recent',
143
+ created_at: '2026-01-20T00:00:00Z'
144
+ },
145
+ {
146
+ number: 2,
147
+ labels: ['false-positive'],
148
+ title: 'Old',
149
+ created_at: '2025-12-15T00:00:00Z'
150
+ }
151
+ ];
152
+
153
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue(mockIssues as any);
154
+
155
+ const listener = new FeedbackListener(mockIssueManager);
156
+ const feedback = await listener.getRecentFeedback(30, now);
157
+
158
+ expect(feedback.falsePositives).toHaveLength(1);
159
+ expect(feedback.falsePositives[0].number).toBe(1);
160
+ });
161
+
162
+ it('should default to current date', async () => {
163
+ vi.mocked(mockIssueManager.listIssues).mockResolvedValue([]);
164
+
165
+ const listener = new FeedbackListener(mockIssueManager);
166
+ const feedback = await listener.getRecentFeedback(30);
167
+
168
+ expect(feedback).toBeDefined();
169
+ });
170
+ });
171
+
172
+ describe('monitorIssue', () => {
173
+ it('should check if issue has feedback label', async () => {
174
+ const issue = {
175
+ number: 1,
176
+ labels: ['false-positive'],
177
+ title: 'Test'
178
+ };
179
+
180
+ vi.mocked(mockIssueManager.getIssue).mockResolvedValue(issue as any);
181
+
182
+ const listener = new FeedbackListener(mockIssueManager);
183
+ const hasFeedback = await listener.monitorIssue(1);
184
+
185
+ expect(hasFeedback).toBe(true);
186
+ });
187
+
188
+ it('should return false for issues without feedback labels', async () => {
189
+ const issue = {
190
+ number: 1,
191
+ labels: ['bug', 'enhancement'],
192
+ title: 'Test'
193
+ };
194
+
195
+ vi.mocked(mockIssueManager.getIssue).mockResolvedValue(issue as any);
196
+
197
+ const listener = new FeedbackListener(mockIssueManager);
198
+ const hasFeedback = await listener.monitorIssue(1);
199
+
200
+ expect(hasFeedback).toBe(false);
201
+ });
202
+ });
203
+ });