memory-journal-mcp 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +131 -122
- package/.gitattributes +29 -0
- package/.github/workflows/docker-publish.yml +1 -1
- package/.github/workflows/lint-and-test.yml +1 -2
- package/.github/workflows/secrets-scanning.yml +0 -1
- package/.github/workflows/security-update.yml +6 -6
- package/.vscode/settings.json +17 -15
- package/CHANGELOG.md +1065 -11
- package/DOCKER_README.md +51 -33
- package/Dockerfile +14 -12
- package/README.md +68 -33
- package/SECURITY.md +225 -220
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/constants/ServerInstructions.d.ts +1 -1
- package/dist/constants/ServerInstructions.d.ts.map +1 -1
- package/dist/constants/ServerInstructions.js +70 -26
- package/dist/constants/ServerInstructions.js.map +1 -1
- package/dist/constants/icons.d.ts +2 -0
- package/dist/constants/icons.d.ts.map +1 -1
- package/dist/constants/icons.js +6 -0
- package/dist/constants/icons.js.map +1 -1
- package/dist/database/SqliteAdapter.d.ts +51 -10
- package/dist/database/SqliteAdapter.d.ts.map +1 -1
- package/dist/database/SqliteAdapter.js +143 -43
- package/dist/database/SqliteAdapter.js.map +1 -1
- package/dist/filtering/ToolFilter.d.ts +1 -1
- package/dist/filtering/ToolFilter.d.ts.map +1 -1
- package/dist/filtering/ToolFilter.js +7 -1
- package/dist/filtering/ToolFilter.js.map +1 -1
- package/dist/github/GitHubIntegration.d.ts +74 -2
- package/dist/github/GitHubIntegration.d.ts.map +1 -1
- package/dist/github/GitHubIntegration.js +508 -7
- package/dist/github/GitHubIntegration.js.map +1 -1
- package/dist/handlers/prompts/index.js +1 -0
- package/dist/handlers/prompts/index.js.map +1 -1
- package/dist/handlers/resources/index.d.ts.map +1 -1
- package/dist/handlers/resources/index.js +257 -13
- package/dist/handlers/resources/index.js.map +1 -1
- package/dist/handlers/tools/index.d.ts.map +1 -1
- package/dist/handlers/tools/index.js +595 -8
- package/dist/handlers/tools/index.js.map +1 -1
- package/dist/server/McpServer.d.ts +2 -0
- package/dist/server/McpServer.d.ts.map +1 -1
- package/dist/server/McpServer.js +69 -26
- package/dist/server/McpServer.js.map +1 -1
- package/dist/types/index.d.ts +97 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +8 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/progress-utils.d.ts +18 -3
- package/dist/utils/progress-utils.d.ts.map +1 -1
- package/dist/utils/progress-utils.js.map +1 -1
- package/dist/utils/security-utils.d.ts +91 -0
- package/dist/utils/security-utils.d.ts.map +1 -0
- package/dist/utils/security-utils.js +184 -0
- package/dist/utils/security-utils.js.map +1 -0
- package/dist/vector/VectorSearchManager.d.ts +2 -1
- package/dist/vector/VectorSearchManager.d.ts.map +1 -1
- package/dist/vector/VectorSearchManager.js +100 -34
- package/dist/vector/VectorSearchManager.js.map +1 -1
- package/docker-compose.yml +46 -37
- package/mcp-config-example.json +0 -2
- package/package.json +21 -14
- package/releases/v4.3.1.md +69 -0
- package/releases/v4.4.0.md +120 -0
- package/server.json +3 -3
- package/src/cli.ts +11 -0
- package/src/constants/ServerInstructions.ts +70 -26
- package/src/constants/icons.ts +7 -0
- package/src/database/SqliteAdapter.ts +165 -44
- package/src/filtering/ToolFilter.ts +7 -1
- package/src/github/GitHubIntegration.ts +588 -8
- package/src/handlers/prompts/index.ts +1 -0
- package/src/handlers/resources/index.ts +318 -12
- package/src/handlers/tools/index.ts +686 -13
- package/src/server/McpServer.ts +79 -37
- package/src/types/index.ts +98 -0
- package/src/utils/logger.ts +10 -1
- package/src/utils/progress-utils.ts +17 -6
- package/src/utils/security-utils.ts +205 -0
- package/src/vector/VectorSearchManager.ts +110 -39
- package/tests/constants/icons.test.ts +102 -0
- package/tests/constants/server-instructions.test.ts +549 -0
- package/tests/database/sqlite-adapter.bench.ts +63 -0
- package/tests/database/sqlite-adapter.test.ts +555 -0
- package/tests/filtering/tool-filter.test.ts +266 -0
- package/tests/github/github-integration.test.ts +1024 -0
- package/tests/handlers/github-resource-handlers.test.ts +473 -0
- package/tests/handlers/github-tool-handlers.test.ts +556 -0
- package/tests/handlers/prompt-handlers.test.ts +91 -0
- package/tests/handlers/resource-handlers.test.ts +339 -0
- package/tests/handlers/tool-handlers.test.ts +497 -0
- package/tests/handlers/vector-tool-handlers.test.ts +238 -0
- package/tests/security/sql-injection.test.ts +347 -0
- package/tests/server/mcp-server.bench.ts +55 -0
- package/tests/server/mcp-server.test.ts +675 -0
- package/tests/utils/logger.test.ts +180 -0
- package/tests/utils/mcp-logger.test.ts +212 -0
- package/tests/utils/progress-utils.test.ts +156 -0
- package/tests/utils/security-utils.test.ts +82 -0
- package/tests/vector/vector-search-manager.test.ts +335 -0
- package/tests/vector/vector-search.bench.ts +53 -0
- package/vitest.config.ts +15 -0
- package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
- package/.github/workflows/dependabot-auto-merge.yml +0 -42
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHubIntegration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the GitHubIntegration class with mocked Octokit, GraphQL, and simple-git.
|
|
5
|
+
* All external API calls are vi.mocked so no network access is needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import { GitHubIntegration } from '../../src/github/GitHubIntegration.js'
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Module mocks
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
// Mock simple-git
|
|
16
|
+
const mockBranch = vi.fn()
|
|
17
|
+
const mockGetRemotes = vi.fn()
|
|
18
|
+
const mockLog = vi.fn()
|
|
19
|
+
|
|
20
|
+
vi.mock('simple-git', () => ({
|
|
21
|
+
simpleGit: () => ({
|
|
22
|
+
branch: mockBranch,
|
|
23
|
+
getRemotes: mockGetRemotes,
|
|
24
|
+
log: mockLog,
|
|
25
|
+
}),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// Helper to create an Octokit mock
|
|
29
|
+
function createOctokitMock() {
|
|
30
|
+
return {
|
|
31
|
+
issues: {
|
|
32
|
+
listForRepo: vi.fn(),
|
|
33
|
+
get: vi.fn(),
|
|
34
|
+
create: vi.fn(),
|
|
35
|
+
update: vi.fn(),
|
|
36
|
+
createComment: vi.fn(),
|
|
37
|
+
listMilestones: vi.fn(),
|
|
38
|
+
getMilestone: vi.fn(),
|
|
39
|
+
createMilestone: vi.fn(),
|
|
40
|
+
updateMilestone: vi.fn(),
|
|
41
|
+
deleteMilestone: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
pulls: {
|
|
44
|
+
list: vi.fn(),
|
|
45
|
+
get: vi.fn(),
|
|
46
|
+
},
|
|
47
|
+
repos: {
|
|
48
|
+
get: vi.fn(),
|
|
49
|
+
},
|
|
50
|
+
rest: {
|
|
51
|
+
actions: {
|
|
52
|
+
listWorkflowRunsForRepo: vi.fn(),
|
|
53
|
+
},
|
|
54
|
+
repos: {
|
|
55
|
+
getClones: vi.fn(),
|
|
56
|
+
getViews: vi.fn(),
|
|
57
|
+
getTopReferrers: vi.fn(),
|
|
58
|
+
getTopPaths: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Helper: inject private fields into GitHubIntegration for testing
|
|
65
|
+
function injectMocks(
|
|
66
|
+
gh: GitHubIntegration,
|
|
67
|
+
octokit: ReturnType<typeof createOctokitMock>,
|
|
68
|
+
graphqlFn?: ReturnType<typeof vi.fn>
|
|
69
|
+
) {
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only field injection
|
|
71
|
+
const inst = gh as any
|
|
72
|
+
inst.octokit = octokit
|
|
73
|
+
if (graphqlFn) inst.graphqlWithAuth = graphqlFn
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('GitHubIntegration', () => {
|
|
77
|
+
let gh: GitHubIntegration
|
|
78
|
+
let octokit: ReturnType<typeof createOctokitMock>
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
// Save and clear env to prevent real token usage
|
|
82
|
+
delete process.env['GITHUB_TOKEN']
|
|
83
|
+
delete process.env['GITHUB_REPO_PATH']
|
|
84
|
+
|
|
85
|
+
gh = new GitHubIntegration('.')
|
|
86
|
+
octokit = createOctokitMock()
|
|
87
|
+
injectMocks(gh, octokit)
|
|
88
|
+
|
|
89
|
+
// Default git mock responses
|
|
90
|
+
mockBranch.mockResolvedValue({ current: 'main' })
|
|
91
|
+
mockGetRemotes.mockResolvedValue([
|
|
92
|
+
{ name: 'origin', refs: { fetch: 'git@github.com:testowner/testrepo.git' } },
|
|
93
|
+
])
|
|
94
|
+
mockLog.mockResolvedValue({ latest: { hash: 'abc1234567890' } })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.restoreAllMocks()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ========================================================================
|
|
102
|
+
// Constructor & API availability
|
|
103
|
+
// ========================================================================
|
|
104
|
+
|
|
105
|
+
describe('isApiAvailable', () => {
|
|
106
|
+
it('should return true when octokit is injected', () => {
|
|
107
|
+
expect(gh.isApiAvailable()).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should return false when no token', () => {
|
|
111
|
+
const noToken = new GitHubIntegration('.')
|
|
112
|
+
expect(noToken.isApiAvailable()).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ========================================================================
|
|
117
|
+
// Cache
|
|
118
|
+
// ========================================================================
|
|
119
|
+
|
|
120
|
+
describe('cache', () => {
|
|
121
|
+
it('should return null for getCachedRepoInfo before getRepoInfo', () => {
|
|
122
|
+
expect(gh.getCachedRepoInfo()).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should cache repo info after getRepoInfo', async () => {
|
|
126
|
+
await gh.getRepoInfo()
|
|
127
|
+
const cached = gh.getCachedRepoInfo()
|
|
128
|
+
expect(cached).not.toBeNull()
|
|
129
|
+
expect(cached!.owner).toBe('testowner')
|
|
130
|
+
expect(cached!.repo).toBe('testrepo')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should clear all cache with clearCache', async () => {
|
|
134
|
+
// Populate cache
|
|
135
|
+
await gh.getRepoInfo()
|
|
136
|
+
|
|
137
|
+
// Call an API method to populate the cache internally
|
|
138
|
+
octokit.issues.listForRepo.mockResolvedValue({
|
|
139
|
+
data: [
|
|
140
|
+
{
|
|
141
|
+
number: 1,
|
|
142
|
+
title: 'Test',
|
|
143
|
+
html_url: 'https://github.com/o/r/issues/1',
|
|
144
|
+
state: 'open',
|
|
145
|
+
pull_request: undefined,
|
|
146
|
+
milestone: null,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
})
|
|
150
|
+
await gh.getIssues('o', 'r')
|
|
151
|
+
|
|
152
|
+
// First call hits API
|
|
153
|
+
expect(octokit.issues.listForRepo).toHaveBeenCalledTimes(1)
|
|
154
|
+
|
|
155
|
+
// Second call should use cache
|
|
156
|
+
await gh.getIssues('o', 'r')
|
|
157
|
+
expect(octokit.issues.listForRepo).toHaveBeenCalledTimes(1)
|
|
158
|
+
|
|
159
|
+
// After clearCache, next call should hit API again
|
|
160
|
+
gh.clearCache()
|
|
161
|
+
await gh.getIssues('o', 'r')
|
|
162
|
+
expect(octokit.issues.listForRepo).toHaveBeenCalledTimes(2)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ========================================================================
|
|
167
|
+
// Git operations
|
|
168
|
+
// ========================================================================
|
|
169
|
+
|
|
170
|
+
describe('getRepoInfo', () => {
|
|
171
|
+
it('should parse SSH remote URL', async () => {
|
|
172
|
+
const info = await gh.getRepoInfo()
|
|
173
|
+
expect(info.owner).toBe('testowner')
|
|
174
|
+
expect(info.repo).toBe('testrepo')
|
|
175
|
+
expect(info.branch).toBe('main')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should parse HTTPS remote URL', async () => {
|
|
179
|
+
mockGetRemotes.mockResolvedValue([
|
|
180
|
+
{ name: 'origin', refs: { fetch: 'https://github.com/httpsowner/httpsrepo.git' } },
|
|
181
|
+
])
|
|
182
|
+
const info = await gh.getRepoInfo()
|
|
183
|
+
expect(info.owner).toBe('httpsowner')
|
|
184
|
+
expect(info.repo).toBe('httpsrepo')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('should handle no origin remote', async () => {
|
|
188
|
+
mockGetRemotes.mockResolvedValue([])
|
|
189
|
+
const info = await gh.getRepoInfo()
|
|
190
|
+
expect(info.owner).toBeNull()
|
|
191
|
+
expect(info.repo).toBeNull()
|
|
192
|
+
expect(info.remoteUrl).toBeNull()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should handle non-github remote', async () => {
|
|
196
|
+
mockGetRemotes.mockResolvedValue([
|
|
197
|
+
{ name: 'origin', refs: { fetch: 'https://gitlab.com/owner/repo.git' } },
|
|
198
|
+
])
|
|
199
|
+
const info = await gh.getRepoInfo()
|
|
200
|
+
expect(info.owner).toBeNull()
|
|
201
|
+
expect(info.repo).toBeNull()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should handle git errors gracefully', async () => {
|
|
205
|
+
mockBranch.mockRejectedValue(new Error('Not a git repo'))
|
|
206
|
+
const info = await gh.getRepoInfo()
|
|
207
|
+
expect(info.owner).toBeNull()
|
|
208
|
+
expect(info.branch).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ========================================================================
|
|
213
|
+
// Issues API
|
|
214
|
+
// ========================================================================
|
|
215
|
+
|
|
216
|
+
describe('getIssues', () => {
|
|
217
|
+
it('should return mapped issues filtering out PRs', async () => {
|
|
218
|
+
octokit.issues.listForRepo.mockResolvedValue({
|
|
219
|
+
data: [
|
|
220
|
+
{
|
|
221
|
+
number: 1,
|
|
222
|
+
title: 'Bug fix',
|
|
223
|
+
html_url: 'https://github.com/o/r/issues/1',
|
|
224
|
+
state: 'open',
|
|
225
|
+
pull_request: undefined,
|
|
226
|
+
milestone: { number: 5, title: 'v1.0' },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
number: 2,
|
|
230
|
+
title: 'PR (should be filtered)',
|
|
231
|
+
html_url: 'https://github.com/o/r/pull/2',
|
|
232
|
+
state: 'open',
|
|
233
|
+
pull_request: { url: 'x' },
|
|
234
|
+
milestone: null,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const issues = await gh.getIssues('o', 'r')
|
|
240
|
+
expect(issues).toHaveLength(1)
|
|
241
|
+
expect(issues[0]!.number).toBe(1)
|
|
242
|
+
expect(issues[0]!.state).toBe('OPEN')
|
|
243
|
+
expect(issues[0]!.milestone?.number).toBe(5)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should return empty when no octokit', async () => {
|
|
247
|
+
injectMocks(gh, null as unknown as ReturnType<typeof createOctokitMock>)
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
;(gh as any).octokit = null
|
|
250
|
+
const issues = await gh.getIssues('o', 'r')
|
|
251
|
+
expect(issues).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should handle API errors gracefully', async () => {
|
|
255
|
+
octokit.issues.listForRepo.mockRejectedValue(new Error('Network error'))
|
|
256
|
+
const issues = await gh.getIssues('o', 'r')
|
|
257
|
+
expect(issues).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('getIssue', () => {
|
|
262
|
+
it('should return issue details', async () => {
|
|
263
|
+
octokit.issues.get.mockResolvedValue({
|
|
264
|
+
data: {
|
|
265
|
+
number: 42,
|
|
266
|
+
title: 'Test issue',
|
|
267
|
+
html_url: 'https://github.com/o/r/issues/42',
|
|
268
|
+
state: 'closed',
|
|
269
|
+
body: 'Description',
|
|
270
|
+
labels: [{ name: 'bug' }],
|
|
271
|
+
assignees: [{ login: 'dev1' }],
|
|
272
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
273
|
+
updated_at: '2025-01-02T00:00:00Z',
|
|
274
|
+
closed_at: '2025-01-02T00:00:00Z',
|
|
275
|
+
comments: 3,
|
|
276
|
+
pull_request: undefined,
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const issue = await gh.getIssue('o', 'r', 42)
|
|
281
|
+
expect(issue).not.toBeNull()
|
|
282
|
+
expect(issue!.number).toBe(42)
|
|
283
|
+
expect(issue!.state).toBe('CLOSED')
|
|
284
|
+
expect(issue!.labels).toEqual(['bug'])
|
|
285
|
+
expect(issue!.commentsCount).toBe(3)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should return null for PR masquerading as issue', async () => {
|
|
289
|
+
octokit.issues.get.mockResolvedValue({
|
|
290
|
+
data: {
|
|
291
|
+
number: 5,
|
|
292
|
+
title: 'PR',
|
|
293
|
+
html_url: 'url',
|
|
294
|
+
state: 'open',
|
|
295
|
+
pull_request: { url: 'x' },
|
|
296
|
+
body: null,
|
|
297
|
+
labels: [],
|
|
298
|
+
assignees: [],
|
|
299
|
+
created_at: 'x',
|
|
300
|
+
updated_at: 'x',
|
|
301
|
+
closed_at: null,
|
|
302
|
+
comments: 0,
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const issue = await gh.getIssue('o', 'r', 5)
|
|
307
|
+
expect(issue).toBeNull()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('should handle API error', async () => {
|
|
311
|
+
octokit.issues.get.mockRejectedValue(new Error('Not found'))
|
|
312
|
+
const issue = await gh.getIssue('o', 'r', 999)
|
|
313
|
+
expect(issue).toBeNull()
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('createIssue', () => {
|
|
318
|
+
it('should create an issue and return result', async () => {
|
|
319
|
+
octokit.issues.create.mockResolvedValue({
|
|
320
|
+
data: {
|
|
321
|
+
number: 10,
|
|
322
|
+
html_url: 'https://github.com/o/r/issues/10',
|
|
323
|
+
title: 'New issue',
|
|
324
|
+
node_id: 'NODE123',
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const result = await gh.createIssue('o', 'r', 'New issue', 'Body text')
|
|
329
|
+
expect(result).not.toBeNull()
|
|
330
|
+
expect(result!.number).toBe(10)
|
|
331
|
+
expect(result!.nodeId).toBe('NODE123')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('should return null on error', async () => {
|
|
335
|
+
octokit.issues.create.mockRejectedValue(new Error('403'))
|
|
336
|
+
const result = await gh.createIssue('o', 'r', 'Fail')
|
|
337
|
+
expect(result).toBeNull()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('should return null when no octokit', async () => {
|
|
341
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
342
|
+
;(gh as any).octokit = null
|
|
343
|
+
const result = await gh.createIssue('o', 'r', 'No API')
|
|
344
|
+
expect(result).toBeNull()
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('closeIssue', () => {
|
|
349
|
+
it('should close an issue', async () => {
|
|
350
|
+
octokit.issues.update.mockResolvedValue({
|
|
351
|
+
data: { html_url: 'https://github.com/o/r/issues/1' },
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const result = await gh.closeIssue('o', 'r', 1)
|
|
355
|
+
expect(result).not.toBeNull()
|
|
356
|
+
expect(result!.success).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('should add comment before closing when provided', async () => {
|
|
360
|
+
octokit.issues.createComment.mockResolvedValue({})
|
|
361
|
+
octokit.issues.update.mockResolvedValue({
|
|
362
|
+
data: { html_url: 'url' },
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await gh.closeIssue('o', 'r', 1, 'Closing comment')
|
|
366
|
+
expect(octokit.issues.createComment).toHaveBeenCalledWith({
|
|
367
|
+
owner: 'o',
|
|
368
|
+
repo: 'r',
|
|
369
|
+
issue_number: 1,
|
|
370
|
+
body: 'Closing comment',
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should return null on error', async () => {
|
|
375
|
+
octokit.issues.update.mockRejectedValue(new Error('fail'))
|
|
376
|
+
const result = await gh.closeIssue('o', 'r', 1)
|
|
377
|
+
expect(result).toBeNull()
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// ========================================================================
|
|
382
|
+
// Pull Requests API
|
|
383
|
+
// ========================================================================
|
|
384
|
+
|
|
385
|
+
describe('getPullRequests', () => {
|
|
386
|
+
it('should return mapped PRs', async () => {
|
|
387
|
+
octokit.pulls.list.mockResolvedValue({
|
|
388
|
+
data: [
|
|
389
|
+
{
|
|
390
|
+
number: 10,
|
|
391
|
+
title: 'Feature PR',
|
|
392
|
+
html_url: 'url',
|
|
393
|
+
state: 'open',
|
|
394
|
+
merged_at: null,
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
number: 11,
|
|
398
|
+
title: 'Merged PR',
|
|
399
|
+
html_url: 'url2',
|
|
400
|
+
state: 'closed',
|
|
401
|
+
merged_at: '2025-01-01T00:00:00Z',
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const prs = await gh.getPullRequests('o', 'r')
|
|
407
|
+
expect(prs).toHaveLength(2)
|
|
408
|
+
expect(prs[0]!.state).toBe('OPEN')
|
|
409
|
+
expect(prs[1]!.state).toBe('MERGED')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('should return empty when no octokit', async () => {
|
|
413
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
414
|
+
;(gh as any).octokit = null
|
|
415
|
+
const prs = await gh.getPullRequests('o', 'r')
|
|
416
|
+
expect(prs).toEqual([])
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
describe('getPullRequest', () => {
|
|
421
|
+
it('should return PR details', async () => {
|
|
422
|
+
octokit.pulls.get.mockResolvedValue({
|
|
423
|
+
data: {
|
|
424
|
+
number: 15,
|
|
425
|
+
title: 'PR',
|
|
426
|
+
html_url: 'url',
|
|
427
|
+
state: 'open',
|
|
428
|
+
merged_at: null,
|
|
429
|
+
body: 'PR body',
|
|
430
|
+
draft: false,
|
|
431
|
+
head: { ref: 'feature' },
|
|
432
|
+
base: { ref: 'main' },
|
|
433
|
+
user: { login: 'author1' },
|
|
434
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
435
|
+
updated_at: '2025-01-02T00:00:00Z',
|
|
436
|
+
closed_at: null,
|
|
437
|
+
additions: 100,
|
|
438
|
+
deletions: 50,
|
|
439
|
+
changed_files: 5,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const pr = await gh.getPullRequest('o', 'r', 15)
|
|
444
|
+
expect(pr).not.toBeNull()
|
|
445
|
+
expect(pr!.headBranch).toBe('feature')
|
|
446
|
+
expect(pr!.baseBranch).toBe('main')
|
|
447
|
+
expect(pr!.additions).toBe(100)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('should handle error', async () => {
|
|
451
|
+
octokit.pulls.get.mockRejectedValue(new Error('Not found'))
|
|
452
|
+
const pr = await gh.getPullRequest('o', 'r', 999)
|
|
453
|
+
expect(pr).toBeNull()
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// ========================================================================
|
|
458
|
+
// Workflow Runs API
|
|
459
|
+
// ========================================================================
|
|
460
|
+
|
|
461
|
+
describe('getWorkflowRuns', () => {
|
|
462
|
+
it('should return mapped workflow runs', async () => {
|
|
463
|
+
octokit.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({
|
|
464
|
+
data: {
|
|
465
|
+
workflow_runs: [
|
|
466
|
+
{
|
|
467
|
+
id: 100,
|
|
468
|
+
name: 'CI',
|
|
469
|
+
status: 'completed',
|
|
470
|
+
conclusion: 'success',
|
|
471
|
+
html_url: 'url',
|
|
472
|
+
head_branch: 'main',
|
|
473
|
+
head_sha: 'abc123',
|
|
474
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
475
|
+
updated_at: '2025-01-01T01:00:00Z',
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
const runs = await gh.getWorkflowRuns('o', 'r')
|
|
482
|
+
expect(runs).toHaveLength(1)
|
|
483
|
+
expect(runs[0]!.name).toBe('CI')
|
|
484
|
+
expect(runs[0]!.conclusion).toBe('success')
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('should return empty when no octokit', async () => {
|
|
488
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
489
|
+
;(gh as any).octokit = null
|
|
490
|
+
const runs = await gh.getWorkflowRuns('o', 'r')
|
|
491
|
+
expect(runs).toEqual([])
|
|
492
|
+
})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// ========================================================================
|
|
496
|
+
// Repository Context
|
|
497
|
+
// ========================================================================
|
|
498
|
+
|
|
499
|
+
describe('getRepoContext', () => {
|
|
500
|
+
it('should aggregate repo info, issues, PRs, workflows, milestones', async () => {
|
|
501
|
+
octokit.issues.listForRepo.mockResolvedValue({ data: [] })
|
|
502
|
+
octokit.pulls.list.mockResolvedValue({ data: [] })
|
|
503
|
+
octokit.rest.actions.listWorkflowRunsForRepo.mockResolvedValue({
|
|
504
|
+
data: { workflow_runs: [] },
|
|
505
|
+
})
|
|
506
|
+
octokit.issues.listMilestones.mockResolvedValue({ data: [] })
|
|
507
|
+
|
|
508
|
+
const ctx = await gh.getRepoContext()
|
|
509
|
+
expect(ctx.repoName).toBe('testrepo')
|
|
510
|
+
expect(ctx.branch).toBe('main')
|
|
511
|
+
expect(ctx.commit).toBe('abc1234567890')
|
|
512
|
+
expect(ctx.issues).toEqual([])
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('should handle missing owner/repo', async () => {
|
|
516
|
+
mockGetRemotes.mockResolvedValue([])
|
|
517
|
+
const ctx = await gh.getRepoContext()
|
|
518
|
+
expect(ctx.issues).toEqual([])
|
|
519
|
+
expect(ctx.pullRequests).toEqual([])
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
// ========================================================================
|
|
524
|
+
// Milestones API
|
|
525
|
+
// ========================================================================
|
|
526
|
+
|
|
527
|
+
describe('getMilestones', () => {
|
|
528
|
+
it('should return mapped milestones', async () => {
|
|
529
|
+
octokit.issues.listMilestones.mockResolvedValue({
|
|
530
|
+
data: [
|
|
531
|
+
{
|
|
532
|
+
number: 1,
|
|
533
|
+
title: 'v1.0',
|
|
534
|
+
description: 'First release',
|
|
535
|
+
state: 'open',
|
|
536
|
+
html_url: 'url',
|
|
537
|
+
due_on: '2025-06-01T00:00:00Z',
|
|
538
|
+
open_issues: 5,
|
|
539
|
+
closed_issues: 10,
|
|
540
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
541
|
+
updated_at: '2025-01-02T00:00:00Z',
|
|
542
|
+
creator: { login: 'owner1' },
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
const milestones = await gh.getMilestones('o', 'r')
|
|
548
|
+
expect(milestones).toHaveLength(1)
|
|
549
|
+
expect(milestones[0]!.title).toBe('v1.0')
|
|
550
|
+
expect(milestones[0]!.openIssues).toBe(5)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('should return empty when no octokit', async () => {
|
|
554
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
555
|
+
;(gh as any).octokit = null
|
|
556
|
+
const ms = await gh.getMilestones('o', 'r')
|
|
557
|
+
expect(ms).toEqual([])
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
describe('getMilestone', () => {
|
|
562
|
+
it('should return single milestone', async () => {
|
|
563
|
+
octokit.issues.getMilestone.mockResolvedValue({
|
|
564
|
+
data: {
|
|
565
|
+
number: 1,
|
|
566
|
+
title: 'v1.0',
|
|
567
|
+
description: null,
|
|
568
|
+
state: 'open',
|
|
569
|
+
html_url: 'url',
|
|
570
|
+
due_on: null,
|
|
571
|
+
open_issues: 2,
|
|
572
|
+
closed_issues: 8,
|
|
573
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
574
|
+
updated_at: '2025-01-02T00:00:00Z',
|
|
575
|
+
creator: null,
|
|
576
|
+
},
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
const ms = await gh.getMilestone('o', 'r', 1)
|
|
580
|
+
expect(ms).not.toBeNull()
|
|
581
|
+
expect(ms!.closedIssues).toBe(8)
|
|
582
|
+
expect(ms!.creator).toBeNull()
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('should handle error', async () => {
|
|
586
|
+
octokit.issues.getMilestone.mockRejectedValue(new Error('Not found'))
|
|
587
|
+
const ms = await gh.getMilestone('o', 'r', 999)
|
|
588
|
+
expect(ms).toBeNull()
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('createMilestone', () => {
|
|
593
|
+
it('should create and return milestone', async () => {
|
|
594
|
+
octokit.issues.createMilestone.mockResolvedValue({
|
|
595
|
+
data: {
|
|
596
|
+
number: 3,
|
|
597
|
+
title: 'v2.0',
|
|
598
|
+
description: 'Next release',
|
|
599
|
+
state: 'open',
|
|
600
|
+
html_url: 'url',
|
|
601
|
+
due_on: null,
|
|
602
|
+
open_issues: 0,
|
|
603
|
+
closed_issues: 0,
|
|
604
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
605
|
+
updated_at: '2025-01-01T00:00:00Z',
|
|
606
|
+
creator: { login: 'me' },
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
const ms = await gh.createMilestone('o', 'r', 'v2.0', 'Next release')
|
|
611
|
+
expect(ms).not.toBeNull()
|
|
612
|
+
expect(ms!.number).toBe(3)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('should return null when no octokit', async () => {
|
|
616
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
617
|
+
;(gh as any).octokit = null
|
|
618
|
+
const ms = await gh.createMilestone('o', 'r', 'v2.0')
|
|
619
|
+
expect(ms).toBeNull()
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('updateMilestone', () => {
|
|
624
|
+
it('should update and return milestone', async () => {
|
|
625
|
+
octokit.issues.updateMilestone.mockResolvedValue({
|
|
626
|
+
data: {
|
|
627
|
+
number: 1,
|
|
628
|
+
title: 'v1.1',
|
|
629
|
+
description: 'Updated',
|
|
630
|
+
state: 'closed',
|
|
631
|
+
html_url: 'url',
|
|
632
|
+
due_on: null,
|
|
633
|
+
open_issues: 0,
|
|
634
|
+
closed_issues: 15,
|
|
635
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
636
|
+
updated_at: '2025-02-01T00:00:00Z',
|
|
637
|
+
creator: { login: 'me' },
|
|
638
|
+
},
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
const ms = await gh.updateMilestone('o', 'r', 1, { title: 'v1.1', state: 'closed' })
|
|
642
|
+
expect(ms).not.toBeNull()
|
|
643
|
+
expect(ms!.state).toBe('closed')
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('should return null when no octokit', async () => {
|
|
647
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
648
|
+
;(gh as any).octokit = null
|
|
649
|
+
const ms = await gh.updateMilestone('o', 'r', 1, { title: 'x' })
|
|
650
|
+
expect(ms).toBeNull()
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
describe('deleteMilestone', () => {
|
|
655
|
+
it('should delete successfully', async () => {
|
|
656
|
+
octokit.issues.deleteMilestone.mockResolvedValue({})
|
|
657
|
+
const result = await gh.deleteMilestone('o', 'r', 1)
|
|
658
|
+
expect(result.success).toBe(true)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('should return error on failure', async () => {
|
|
662
|
+
octokit.issues.deleteMilestone.mockRejectedValue(new Error('Forbidden'))
|
|
663
|
+
const result = await gh.deleteMilestone('o', 'r', 1)
|
|
664
|
+
expect(result.success).toBe(false)
|
|
665
|
+
expect(result.error).toContain('Forbidden')
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('should return error when no octokit', async () => {
|
|
669
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
670
|
+
;(gh as any).octokit = null
|
|
671
|
+
const result = await gh.deleteMilestone('o', 'r', 1)
|
|
672
|
+
expect(result.success).toBe(false)
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// ========================================================================
|
|
677
|
+
// GraphQL - Project Kanban
|
|
678
|
+
// ========================================================================
|
|
679
|
+
|
|
680
|
+
describe('getProjectKanban', () => {
|
|
681
|
+
it('should return null when no graphql', async () => {
|
|
682
|
+
const board = await gh.getProjectKanban('o', 1)
|
|
683
|
+
expect(board).toBeNull()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('should search user projects first', async () => {
|
|
687
|
+
const mockGraphql = vi.fn()
|
|
688
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
689
|
+
|
|
690
|
+
mockGraphql.mockResolvedValueOnce({
|
|
691
|
+
user: {
|
|
692
|
+
projectV2: {
|
|
693
|
+
id: 'PVT_1',
|
|
694
|
+
title: 'My Board',
|
|
695
|
+
fields: {
|
|
696
|
+
nodes: [
|
|
697
|
+
{
|
|
698
|
+
id: 'FIELD_1',
|
|
699
|
+
name: 'Status',
|
|
700
|
+
options: [
|
|
701
|
+
{ id: 'OPT_TODO', name: 'Todo', color: 'GREEN' },
|
|
702
|
+
{ id: 'OPT_DONE', name: 'Done', color: 'BLUE' },
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
},
|
|
707
|
+
items: {
|
|
708
|
+
nodes: [
|
|
709
|
+
{
|
|
710
|
+
id: 'ITEM_1',
|
|
711
|
+
type: 'ISSUE',
|
|
712
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
713
|
+
updatedAt: '2025-01-02T00:00:00Z',
|
|
714
|
+
fieldValues: {
|
|
715
|
+
nodes: [
|
|
716
|
+
{
|
|
717
|
+
name: 'Todo',
|
|
718
|
+
field: { name: 'Status' },
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
content: {
|
|
723
|
+
number: 5,
|
|
724
|
+
title: 'Test Issue',
|
|
725
|
+
url: 'https://github.com/o/r/issues/5',
|
|
726
|
+
labels: { nodes: [{ name: 'bug' }] },
|
|
727
|
+
assignees: { nodes: [{ login: 'dev' }] },
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
const board = await gh.getProjectKanban('o', 1)
|
|
737
|
+
expect(board).not.toBeNull()
|
|
738
|
+
expect(board!.projectTitle).toBe('My Board')
|
|
739
|
+
expect(board!.columns.length).toBeGreaterThan(0)
|
|
740
|
+
expect(board!.totalItems).toBe(1)
|
|
741
|
+
|
|
742
|
+
// Check item was placed in correct column
|
|
743
|
+
const todoCol = board!.columns.find((c) => c.status === 'Todo')
|
|
744
|
+
expect(todoCol).toBeDefined()
|
|
745
|
+
expect(todoCol!.items).toHaveLength(1)
|
|
746
|
+
expect(todoCol!.items[0]!.title).toBe('Test Issue')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('should fall back to repo then org projects', async () => {
|
|
750
|
+
const mockGraphql = vi.fn()
|
|
751
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
752
|
+
|
|
753
|
+
// User query returns null
|
|
754
|
+
mockGraphql.mockResolvedValueOnce({ user: { projectV2: null } })
|
|
755
|
+
// Repo query returns null
|
|
756
|
+
mockGraphql.mockResolvedValueOnce({ repository: { projectV2: null } })
|
|
757
|
+
// Org query returns null
|
|
758
|
+
mockGraphql.mockResolvedValueOnce({ organization: { projectV2: null } })
|
|
759
|
+
|
|
760
|
+
const board = await gh.getProjectKanban('o', 99, 'r')
|
|
761
|
+
expect(board).toBeNull()
|
|
762
|
+
|
|
763
|
+
// Verify all 3 queries were attempted
|
|
764
|
+
expect(mockGraphql).toHaveBeenCalledTimes(3)
|
|
765
|
+
})
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
// ========================================================================
|
|
769
|
+
// GraphQL - Move/Add Project Items
|
|
770
|
+
// ========================================================================
|
|
771
|
+
|
|
772
|
+
describe('moveProjectItem', () => {
|
|
773
|
+
it('should return success on mutation', async () => {
|
|
774
|
+
const mockGraphql = vi.fn()
|
|
775
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
776
|
+
|
|
777
|
+
mockGraphql.mockResolvedValue({
|
|
778
|
+
updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM_1' } },
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
const result = await gh.moveProjectItem('PVT_1', 'ITEM_1', 'FIELD_1', 'OPT_DONE')
|
|
782
|
+
expect(result.success).toBe(true)
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('should return error when no graphql', async () => {
|
|
786
|
+
const result = await gh.moveProjectItem('P', 'I', 'F', 'O')
|
|
787
|
+
expect(result.success).toBe(false)
|
|
788
|
+
expect(result.error).toContain('GraphQL not available')
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('should handle mutation error', async () => {
|
|
792
|
+
const mockGraphql = vi.fn()
|
|
793
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
794
|
+
mockGraphql.mockRejectedValue(new Error('Mutation failed'))
|
|
795
|
+
|
|
796
|
+
const result = await gh.moveProjectItem('P', 'I', 'F', 'O')
|
|
797
|
+
expect(result.success).toBe(false)
|
|
798
|
+
expect(result.error).toContain('Mutation failed')
|
|
799
|
+
})
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
describe('addProjectItem', () => {
|
|
803
|
+
it('should add item and return itemId', async () => {
|
|
804
|
+
const mockGraphql = vi.fn()
|
|
805
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
806
|
+
|
|
807
|
+
mockGraphql.mockResolvedValue({
|
|
808
|
+
addProjectV2ItemById: { item: { id: 'PVTITEM_NEW' } },
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
const result = await gh.addProjectItem('PVT_1', 'NODE_1')
|
|
812
|
+
expect(result.success).toBe(true)
|
|
813
|
+
expect(result.itemId).toBe('PVTITEM_NEW')
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
it('should return error when no graphql', async () => {
|
|
817
|
+
const result = await gh.addProjectItem('P', 'C')
|
|
818
|
+
expect(result.success).toBe(false)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('should handle error', async () => {
|
|
822
|
+
const mockGraphql = vi.fn()
|
|
823
|
+
injectMocks(gh, octokit, mockGraphql)
|
|
824
|
+
mockGraphql.mockRejectedValue(new Error('Failed'))
|
|
825
|
+
|
|
826
|
+
const result = await gh.addProjectItem('P', 'C')
|
|
827
|
+
expect(result.success).toBe(false)
|
|
828
|
+
expect(result.error).toContain('Failed')
|
|
829
|
+
})
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
// ==========================================================================
|
|
833
|
+
// Repository Insights/Traffic Tests
|
|
834
|
+
// ==========================================================================
|
|
835
|
+
|
|
836
|
+
describe('getRepoStats', () => {
|
|
837
|
+
it('should return repo stats', async () => {
|
|
838
|
+
octokit.repos.get.mockResolvedValue({
|
|
839
|
+
data: {
|
|
840
|
+
stargazers_count: 42,
|
|
841
|
+
forks_count: 10,
|
|
842
|
+
subscribers_count: 5,
|
|
843
|
+
open_issues_count: 3,
|
|
844
|
+
size: 1024,
|
|
845
|
+
default_branch: 'main',
|
|
846
|
+
},
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
const result = await gh.getRepoStats('testowner', 'testrepo')
|
|
850
|
+
expect(result).toEqual({
|
|
851
|
+
stars: 42,
|
|
852
|
+
forks: 10,
|
|
853
|
+
watchers: 5,
|
|
854
|
+
openIssues: 3,
|
|
855
|
+
size: 1024,
|
|
856
|
+
defaultBranch: 'main',
|
|
857
|
+
})
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('should return null without octokit', async () => {
|
|
861
|
+
const bare = new GitHubIntegration('.')
|
|
862
|
+
const result = await bare.getRepoStats('o', 'r')
|
|
863
|
+
expect(result).toBeNull()
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('should return null on API error', async () => {
|
|
867
|
+
octokit.repos.get.mockRejectedValue(new Error('Not found'))
|
|
868
|
+
const result = await gh.getRepoStats('o', 'r')
|
|
869
|
+
expect(result).toBeNull()
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('should cache results with extended TTL', async () => {
|
|
873
|
+
octokit.repos.get.mockResolvedValue({
|
|
874
|
+
data: {
|
|
875
|
+
stargazers_count: 1,
|
|
876
|
+
forks_count: 0,
|
|
877
|
+
subscribers_count: 0,
|
|
878
|
+
open_issues_count: 0,
|
|
879
|
+
size: 100,
|
|
880
|
+
default_branch: 'main',
|
|
881
|
+
},
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
await gh.getRepoStats('o', 'r')
|
|
885
|
+
await gh.getRepoStats('o', 'r')
|
|
886
|
+
expect(octokit.repos.get).toHaveBeenCalledTimes(1)
|
|
887
|
+
})
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
describe('getTrafficData', () => {
|
|
891
|
+
it('should return aggregated traffic data', async () => {
|
|
892
|
+
octokit.rest.repos.getClones.mockResolvedValue({
|
|
893
|
+
data: {
|
|
894
|
+
count: 100,
|
|
895
|
+
uniques: 50,
|
|
896
|
+
clones: new Array(10).fill({ count: 10, uniques: 5 }),
|
|
897
|
+
},
|
|
898
|
+
})
|
|
899
|
+
octokit.rest.repos.getViews.mockResolvedValue({
|
|
900
|
+
data: {
|
|
901
|
+
count: 500,
|
|
902
|
+
uniques: 200,
|
|
903
|
+
views: new Array(14).fill({ count: 36, uniques: 14 }),
|
|
904
|
+
},
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
const result = await gh.getTrafficData('testowner', 'testrepo')
|
|
908
|
+
expect(result).toEqual({
|
|
909
|
+
clones: { total: 100, unique: 50, dailyAvg: 10 },
|
|
910
|
+
views: { total: 500, unique: 200, dailyAvg: 36 },
|
|
911
|
+
period: '14 days',
|
|
912
|
+
})
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('should return null without octokit', async () => {
|
|
916
|
+
const bare = new GitHubIntegration('.')
|
|
917
|
+
const result = await bare.getTrafficData('o', 'r')
|
|
918
|
+
expect(result).toBeNull()
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('should return null on API error', async () => {
|
|
922
|
+
octokit.rest.repos.getClones.mockRejectedValue(new Error('Forbidden'))
|
|
923
|
+
octokit.rest.repos.getViews.mockRejectedValue(new Error('Forbidden'))
|
|
924
|
+
const result = await gh.getTrafficData('o', 'r')
|
|
925
|
+
expect(result).toBeNull()
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('should handle zero days gracefully', async () => {
|
|
929
|
+
octokit.rest.repos.getClones.mockResolvedValue({
|
|
930
|
+
data: { count: 0, uniques: 0, clones: [] },
|
|
931
|
+
})
|
|
932
|
+
octokit.rest.repos.getViews.mockResolvedValue({
|
|
933
|
+
data: { count: 0, uniques: 0, views: [] },
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
const result = await gh.getTrafficData('o', 'r')
|
|
937
|
+
expect(result).toEqual({
|
|
938
|
+
clones: { total: 0, unique: 0, dailyAvg: 0 },
|
|
939
|
+
views: { total: 0, unique: 0, dailyAvg: 0 },
|
|
940
|
+
period: '14 days',
|
|
941
|
+
})
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
describe('getTopReferrers', () => {
|
|
946
|
+
it('should return referrer list', async () => {
|
|
947
|
+
octokit.rest.repos.getTopReferrers.mockResolvedValue({
|
|
948
|
+
data: [
|
|
949
|
+
{ referrer: 'google.com', count: 100, uniques: 50 },
|
|
950
|
+
{ referrer: 'github.com', count: 80, uniques: 40 },
|
|
951
|
+
],
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
const result = await gh.getTopReferrers('testowner', 'testrepo')
|
|
955
|
+
expect(result).toHaveLength(2)
|
|
956
|
+
expect(result[0]).toEqual({ referrer: 'google.com', count: 100, uniques: 50 })
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it('should respect limit parameter', async () => {
|
|
960
|
+
octokit.rest.repos.getTopReferrers.mockResolvedValue({
|
|
961
|
+
data: [
|
|
962
|
+
{ referrer: 'a.com', count: 10, uniques: 5 },
|
|
963
|
+
{ referrer: 'b.com', count: 8, uniques: 4 },
|
|
964
|
+
{ referrer: 'c.com', count: 6, uniques: 3 },
|
|
965
|
+
],
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
const result = await gh.getTopReferrers('o', 'r', 2)
|
|
969
|
+
expect(result).toHaveLength(2)
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('should return empty array without octokit', async () => {
|
|
973
|
+
const bare = new GitHubIntegration('.')
|
|
974
|
+
const result = await bare.getTopReferrers('o', 'r')
|
|
975
|
+
expect(result).toEqual([])
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
it('should return empty array on error', async () => {
|
|
979
|
+
octokit.rest.repos.getTopReferrers.mockRejectedValue(new Error('Forbidden'))
|
|
980
|
+
const result = await gh.getTopReferrers('o', 'r')
|
|
981
|
+
expect(result).toEqual([])
|
|
982
|
+
})
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
describe('getPopularPaths', () => {
|
|
986
|
+
it('should return popular paths', async () => {
|
|
987
|
+
octokit.rest.repos.getTopPaths.mockResolvedValue({
|
|
988
|
+
data: [
|
|
989
|
+
{ path: '/repo', title: 'repo', count: 200, uniques: 100 },
|
|
990
|
+
{ path: '/repo/issues', title: 'Issues', count: 50, uniques: 30 },
|
|
991
|
+
],
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
const result = await gh.getPopularPaths('testowner', 'testrepo')
|
|
995
|
+
expect(result).toHaveLength(2)
|
|
996
|
+
expect(result[0]).toEqual({ path: '/repo', title: 'repo', count: 200, uniques: 100 })
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
it('should respect limit parameter', async () => {
|
|
1000
|
+
octokit.rest.repos.getTopPaths.mockResolvedValue({
|
|
1001
|
+
data: [
|
|
1002
|
+
{ path: '/a', title: 'A', count: 10, uniques: 5 },
|
|
1003
|
+
{ path: '/b', title: 'B', count: 8, uniques: 4 },
|
|
1004
|
+
{ path: '/c', title: 'C', count: 6, uniques: 3 },
|
|
1005
|
+
],
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
const result = await gh.getPopularPaths('o', 'r', 1)
|
|
1009
|
+
expect(result).toHaveLength(1)
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
it('should return empty array without octokit', async () => {
|
|
1013
|
+
const bare = new GitHubIntegration('.')
|
|
1014
|
+
const result = await bare.getPopularPaths('o', 'r')
|
|
1015
|
+
expect(result).toEqual([])
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('should return empty array on error', async () => {
|
|
1019
|
+
octokit.rest.repos.getTopPaths.mockRejectedValue(new Error('Forbidden'))
|
|
1020
|
+
const result = await gh.getPopularPaths('o', 'r')
|
|
1021
|
+
expect(result).toEqual([])
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
})
|