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.
Files changed (109) hide show
  1. package/.dockerignore +131 -122
  2. package/.gitattributes +29 -0
  3. package/.github/workflows/docker-publish.yml +1 -1
  4. package/.github/workflows/lint-and-test.yml +1 -2
  5. package/.github/workflows/secrets-scanning.yml +0 -1
  6. package/.github/workflows/security-update.yml +6 -6
  7. package/.vscode/settings.json +17 -15
  8. package/CHANGELOG.md +1065 -11
  9. package/DOCKER_README.md +51 -33
  10. package/Dockerfile +14 -12
  11. package/README.md +68 -33
  12. package/SECURITY.md +225 -220
  13. package/dist/cli.js +7 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/constants/ServerInstructions.d.ts +1 -1
  16. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  17. package/dist/constants/ServerInstructions.js +70 -26
  18. package/dist/constants/ServerInstructions.js.map +1 -1
  19. package/dist/constants/icons.d.ts +2 -0
  20. package/dist/constants/icons.d.ts.map +1 -1
  21. package/dist/constants/icons.js +6 -0
  22. package/dist/constants/icons.js.map +1 -1
  23. package/dist/database/SqliteAdapter.d.ts +51 -10
  24. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  25. package/dist/database/SqliteAdapter.js +143 -43
  26. package/dist/database/SqliteAdapter.js.map +1 -1
  27. package/dist/filtering/ToolFilter.d.ts +1 -1
  28. package/dist/filtering/ToolFilter.d.ts.map +1 -1
  29. package/dist/filtering/ToolFilter.js +7 -1
  30. package/dist/filtering/ToolFilter.js.map +1 -1
  31. package/dist/github/GitHubIntegration.d.ts +74 -2
  32. package/dist/github/GitHubIntegration.d.ts.map +1 -1
  33. package/dist/github/GitHubIntegration.js +508 -7
  34. package/dist/github/GitHubIntegration.js.map +1 -1
  35. package/dist/handlers/prompts/index.js +1 -0
  36. package/dist/handlers/prompts/index.js.map +1 -1
  37. package/dist/handlers/resources/index.d.ts.map +1 -1
  38. package/dist/handlers/resources/index.js +257 -13
  39. package/dist/handlers/resources/index.js.map +1 -1
  40. package/dist/handlers/tools/index.d.ts.map +1 -1
  41. package/dist/handlers/tools/index.js +595 -8
  42. package/dist/handlers/tools/index.js.map +1 -1
  43. package/dist/server/McpServer.d.ts +2 -0
  44. package/dist/server/McpServer.d.ts.map +1 -1
  45. package/dist/server/McpServer.js +69 -26
  46. package/dist/server/McpServer.js.map +1 -1
  47. package/dist/types/index.d.ts +97 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/utils/logger.d.ts +1 -0
  51. package/dist/utils/logger.d.ts.map +1 -1
  52. package/dist/utils/logger.js +8 -1
  53. package/dist/utils/logger.js.map +1 -1
  54. package/dist/utils/progress-utils.d.ts +18 -3
  55. package/dist/utils/progress-utils.d.ts.map +1 -1
  56. package/dist/utils/progress-utils.js.map +1 -1
  57. package/dist/utils/security-utils.d.ts +91 -0
  58. package/dist/utils/security-utils.d.ts.map +1 -0
  59. package/dist/utils/security-utils.js +184 -0
  60. package/dist/utils/security-utils.js.map +1 -0
  61. package/dist/vector/VectorSearchManager.d.ts +2 -1
  62. package/dist/vector/VectorSearchManager.d.ts.map +1 -1
  63. package/dist/vector/VectorSearchManager.js +100 -34
  64. package/dist/vector/VectorSearchManager.js.map +1 -1
  65. package/docker-compose.yml +46 -37
  66. package/mcp-config-example.json +0 -2
  67. package/package.json +21 -14
  68. package/releases/v4.3.1.md +69 -0
  69. package/releases/v4.4.0.md +120 -0
  70. package/server.json +3 -3
  71. package/src/cli.ts +11 -0
  72. package/src/constants/ServerInstructions.ts +70 -26
  73. package/src/constants/icons.ts +7 -0
  74. package/src/database/SqliteAdapter.ts +165 -44
  75. package/src/filtering/ToolFilter.ts +7 -1
  76. package/src/github/GitHubIntegration.ts +588 -8
  77. package/src/handlers/prompts/index.ts +1 -0
  78. package/src/handlers/resources/index.ts +318 -12
  79. package/src/handlers/tools/index.ts +686 -13
  80. package/src/server/McpServer.ts +79 -37
  81. package/src/types/index.ts +98 -0
  82. package/src/utils/logger.ts +10 -1
  83. package/src/utils/progress-utils.ts +17 -6
  84. package/src/utils/security-utils.ts +205 -0
  85. package/src/vector/VectorSearchManager.ts +110 -39
  86. package/tests/constants/icons.test.ts +102 -0
  87. package/tests/constants/server-instructions.test.ts +549 -0
  88. package/tests/database/sqlite-adapter.bench.ts +63 -0
  89. package/tests/database/sqlite-adapter.test.ts +555 -0
  90. package/tests/filtering/tool-filter.test.ts +266 -0
  91. package/tests/github/github-integration.test.ts +1024 -0
  92. package/tests/handlers/github-resource-handlers.test.ts +473 -0
  93. package/tests/handlers/github-tool-handlers.test.ts +556 -0
  94. package/tests/handlers/prompt-handlers.test.ts +91 -0
  95. package/tests/handlers/resource-handlers.test.ts +339 -0
  96. package/tests/handlers/tool-handlers.test.ts +497 -0
  97. package/tests/handlers/vector-tool-handlers.test.ts +238 -0
  98. package/tests/security/sql-injection.test.ts +347 -0
  99. package/tests/server/mcp-server.bench.ts +55 -0
  100. package/tests/server/mcp-server.test.ts +675 -0
  101. package/tests/utils/logger.test.ts +180 -0
  102. package/tests/utils/mcp-logger.test.ts +212 -0
  103. package/tests/utils/progress-utils.test.ts +156 -0
  104. package/tests/utils/security-utils.test.ts +82 -0
  105. package/tests/vector/vector-search-manager.test.ts +335 -0
  106. package/tests/vector/vector-search.bench.ts +53 -0
  107. package/vitest.config.ts +15 -0
  108. package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
  109. 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
+ })