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
@@ -11,6 +11,7 @@ import * as simpleGitImport from 'simple-git'
11
11
  import { logger } from '../utils/logger.js'
12
12
  import type {
13
13
  GitHubIssue,
14
+ GitHubMilestone,
14
15
  GitHubPullRequest,
15
16
  GitHubWorkflowRun,
16
17
  ProjectContext,
@@ -18,8 +19,24 @@ import type {
18
19
  KanbanColumn,
19
20
  ProjectV2Item,
20
21
  ProjectV2StatusOption,
22
+ RepoStats,
23
+ TrafficData,
24
+ TrafficReferrer,
25
+ PopularPath,
21
26
  } from '../types/index.js'
22
27
 
28
+ /** TTL for cached GitHub API responses (5 minutes) */
29
+ const CACHE_TTL_MS = 5 * 60 * 1000
30
+
31
+ /** TTL for cached traffic/insights responses (10 minutes - traffic data changes slowly) */
32
+ const TRAFFIC_CACHE_TTL_MS = 10 * 60 * 1000
33
+
34
+ /** Generic cache entry with timestamp */
35
+ interface CacheEntry<T> {
36
+ data: T
37
+ timestamp: number
38
+ }
39
+
23
40
  // Handle simpleGit ESM/CJS interop
24
41
  type SimpleGitType = typeof simpleGitImport.simpleGit
25
42
  const simpleGit: SimpleGitType = simpleGitImport.simpleGit
@@ -75,6 +92,9 @@ export class GitHubIntegration {
75
92
  private readonly token: string | undefined
76
93
  private cachedRepoInfo: RepoInfo | null = null
77
94
 
95
+ /** TTL response cache for GitHub API read methods */
96
+ private readonly apiCache = new Map<string, CacheEntry<unknown>>()
97
+
78
98
  constructor(workingDir = '.') {
79
99
  this.token = process.env['GITHUB_TOKEN']
80
100
 
@@ -116,6 +136,47 @@ export class GitHubIntegration {
116
136
  return this.octokit !== null
117
137
  }
118
138
 
139
+ /**
140
+ * Get a cached value if it exists and hasn't expired.
141
+ */
142
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is needed at call sites for type-safe casts
143
+ private getCached<T>(key: string): T | undefined {
144
+ const entry = this.apiCache.get(key)
145
+ if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
146
+ return entry.data as T
147
+ }
148
+ if (entry) {
149
+ this.apiCache.delete(key)
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ /**
155
+ * Store a value in the cache.
156
+ */
157
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T preserves type inference at call sites
158
+ private setCache<T>(key: string, data: T): void {
159
+ this.apiCache.set(key, { data, timestamp: Date.now() })
160
+ }
161
+
162
+ /**
163
+ * Invalidate all cache entries matching a prefix.
164
+ */
165
+ private invalidateCache(prefix: string): void {
166
+ for (const key of this.apiCache.keys()) {
167
+ if (key.startsWith(prefix)) {
168
+ this.apiCache.delete(key)
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Clear all cached GitHub API responses.
175
+ */
176
+ clearCache(): void {
177
+ this.apiCache.clear()
178
+ }
179
+
119
180
  /**
120
181
  * Get local repository information
121
182
  * Caches the result for synchronous access via getCachedRepoInfo()
@@ -204,6 +265,10 @@ export class GitHubIntegration {
204
265
  return []
205
266
  }
206
267
 
268
+ const cacheKey = `issues:${owner}:${repo}:${state}:${String(limit)}`
269
+ const cached = this.getCached<GitHubIssue[]>(cacheKey)
270
+ if (cached) return cached
271
+
207
272
  try {
208
273
  const response = await this.octokit.issues.listForRepo({
209
274
  owner,
@@ -215,14 +280,23 @@ export class GitHubIntegration {
215
280
  })
216
281
 
217
282
  // Filter out pull requests (GitHub API includes PRs in issues)
218
- return response.data
283
+ const result = response.data
219
284
  .filter((issue) => !issue.pull_request)
220
285
  .map((issue) => ({
221
286
  number: issue.number,
222
287
  title: issue.title,
223
288
  url: issue.html_url,
224
- state: issue.state === 'open' ? 'OPEN' : 'CLOSED',
289
+ state: issue.state === 'open' ? ('OPEN' as const) : ('CLOSED' as const),
290
+ milestone: issue.milestone
291
+ ? {
292
+ number: issue.milestone.number,
293
+ title: issue.milestone.title,
294
+ }
295
+ : null,
225
296
  }))
297
+
298
+ this.setCache(cacheKey, result)
299
+ return result
226
300
  } catch (error) {
227
301
  logger.error('Failed to get issues', {
228
302
  module: 'GitHub',
@@ -240,6 +314,10 @@ export class GitHubIntegration {
240
314
  return null
241
315
  }
242
316
 
317
+ const cacheKey = `issue:${owner}:${repo}:${String(issueNumber)}`
318
+ const cached = this.getCached<IssueDetails | null>(cacheKey)
319
+ if (cached !== undefined) return cached
320
+
243
321
  try {
244
322
  const response = await this.octokit.issues.get({
245
323
  owner,
@@ -254,7 +332,7 @@ export class GitHubIntegration {
254
332
  return null
255
333
  }
256
334
 
257
- return {
335
+ const details: IssueDetails = {
258
336
  number: issue.number,
259
337
  title: issue.title,
260
338
  url: issue.html_url,
@@ -266,7 +344,13 @@ export class GitHubIntegration {
266
344
  updatedAt: issue.updated_at,
267
345
  closedAt: issue.closed_at,
268
346
  commentsCount: issue.comments,
347
+ milestone: issue.milestone
348
+ ? { number: issue.milestone.number, title: issue.milestone.title }
349
+ : null,
269
350
  }
351
+
352
+ this.setCache(cacheKey, details)
353
+ return details
270
354
  } catch (error) {
271
355
  logger.error('Failed to get issue details', {
272
356
  module: 'GitHub',
@@ -286,7 +370,8 @@ export class GitHubIntegration {
286
370
  title: string,
287
371
  body?: string,
288
372
  labels?: string[],
289
- assignees?: string[]
373
+ assignees?: string[],
374
+ milestone?: number
290
375
  ): Promise<{ number: number; url: string; title: string; nodeId: string } | null> {
291
376
  if (!this.octokit) {
292
377
  logger.error('Cannot create issue: GitHub API not available', { module: 'GitHub' })
@@ -301,6 +386,7 @@ export class GitHubIntegration {
301
386
  body,
302
387
  labels,
303
388
  assignees,
389
+ milestone,
304
390
  })
305
391
 
306
392
  logger.info('Created GitHub issue', {
@@ -322,6 +408,9 @@ export class GitHubIntegration {
322
408
  context: { title, owner, repo },
323
409
  })
324
410
  return null
411
+ } finally {
412
+ this.invalidateCache(`issues:${owner}:${repo}`)
413
+ this.invalidateCache('context:')
325
414
  }
326
415
  }
327
416
 
@@ -375,6 +464,10 @@ export class GitHubIntegration {
375
464
  error: error instanceof Error ? error.message : String(error),
376
465
  })
377
466
  return null
467
+ } finally {
468
+ this.invalidateCache(`issues:${owner}:${repo}`)
469
+ this.invalidateCache(`issue:${owner}:${repo}:${String(issueNumber)}`)
470
+ this.invalidateCache('context:')
378
471
  }
379
472
  }
380
473
 
@@ -391,6 +484,10 @@ export class GitHubIntegration {
391
484
  return []
392
485
  }
393
486
 
487
+ const cacheKey = `prs:${owner}:${repo}:${state}:${String(limit)}`
488
+ const cached = this.getCached<GitHubPullRequest[]>(cacheKey)
489
+ if (cached) return cached
490
+
394
491
  try {
395
492
  const response = await this.octokit.pulls.list({
396
493
  owner,
@@ -401,12 +498,19 @@ export class GitHubIntegration {
401
498
  direction: 'desc',
402
499
  })
403
500
 
404
- return response.data.map((pr) => ({
501
+ const result = response.data.map((pr) => ({
405
502
  number: pr.number,
406
503
  title: pr.title,
407
504
  url: pr.html_url,
408
- state: pr.merged_at ? 'MERGED' : pr.state === 'open' ? 'OPEN' : 'CLOSED',
505
+ state: pr.merged_at
506
+ ? ('MERGED' as const)
507
+ : pr.state === 'open'
508
+ ? ('OPEN' as const)
509
+ : ('CLOSED' as const),
409
510
  }))
511
+
512
+ this.setCache(cacheKey, result)
513
+ return result
410
514
  } catch (error) {
411
515
  logger.error('Failed to get pull requests', {
412
516
  module: 'GitHub',
@@ -428,6 +532,10 @@ export class GitHubIntegration {
428
532
  return null
429
533
  }
430
534
 
535
+ const cacheKey = `pr:${owner}:${repo}:${String(prNumber)}`
536
+ const cached = this.getCached<PullRequestDetails | null>(cacheKey)
537
+ if (cached !== undefined) return cached
538
+
431
539
  try {
432
540
  const response = await this.octokit.pulls.get({
433
541
  owner,
@@ -437,7 +545,7 @@ export class GitHubIntegration {
437
545
 
438
546
  const pr = response.data
439
547
 
440
- return {
548
+ const details: PullRequestDetails = {
441
549
  number: pr.number,
442
550
  title: pr.title,
443
551
  url: pr.html_url,
@@ -455,6 +563,9 @@ export class GitHubIntegration {
455
563
  deletions: pr.deletions,
456
564
  changedFiles: pr.changed_files,
457
565
  }
566
+
567
+ this.setCache(cacheKey, details)
568
+ return details
458
569
  } catch (error) {
459
570
  logger.error('Failed to get PR details', {
460
571
  module: 'GitHub',
@@ -474,6 +585,10 @@ export class GitHubIntegration {
474
585
  return []
475
586
  }
476
587
 
588
+ const cacheKey = `workflows:${owner}:${repo}:${String(limit)}`
589
+ const cached = this.getCached<GitHubWorkflowRun[]>(cacheKey)
590
+ if (cached) return cached
591
+
477
592
  try {
478
593
  const response = await this.octokit.rest.actions.listWorkflowRunsForRepo({
479
594
  owner,
@@ -481,7 +596,7 @@ export class GitHubIntegration {
481
596
  per_page: limit,
482
597
  })
483
598
 
484
- return response.data.workflow_runs.map((run) => ({
599
+ const result = response.data.workflow_runs.map((run) => ({
485
600
  id: run.id,
486
601
  name: run.name ?? 'Unknown Workflow',
487
602
  status: run.status as 'queued' | 'in_progress' | 'completed',
@@ -497,6 +612,9 @@ export class GitHubIntegration {
497
612
  createdAt: run.created_at,
498
613
  updatedAt: run.updated_at,
499
614
  }))
615
+
616
+ this.setCache(cacheKey, result)
617
+ return result
500
618
  } catch (error) {
501
619
  logger.error('Failed to get workflow runs', {
502
620
  module: 'GitHub',
@@ -510,6 +628,9 @@ export class GitHubIntegration {
510
628
  * Get full repository context (issues, PRs, branch info)
511
629
  */
512
630
  async getRepoContext(): Promise<ProjectContext> {
631
+ const cached = this.getCached<ProjectContext>('context:repo')
632
+ if (cached) return cached
633
+
513
634
  const repoInfo = await this.getRepoInfo()
514
635
 
515
636
  const context: ProjectContext = {
@@ -521,6 +642,7 @@ export class GitHubIntegration {
521
642
  issues: [],
522
643
  pullRequests: [],
523
644
  workflowRuns: [],
645
+ milestones: [],
524
646
  }
525
647
 
526
648
  // Get current commit
@@ -541,8 +663,10 @@ export class GitHubIntegration {
541
663
  10
542
664
  )
543
665
  context.workflowRuns = await this.getWorkflowRuns(repoInfo.owner, repoInfo.repo, 10)
666
+ context.milestones = await this.getMilestones(repoInfo.owner, repoInfo.repo, 'open', 10)
544
667
  }
545
668
 
669
+ this.setCache('context:repo', context)
546
670
  return context
547
671
  }
548
672
 
@@ -923,6 +1047,8 @@ export class GitHubIntegration {
923
1047
  error: errorMessage,
924
1048
  })
925
1049
  return { success: false, error: errorMessage }
1050
+ } finally {
1051
+ this.invalidateCache('kanban:')
926
1052
  }
927
1053
  }
928
1054
 
@@ -971,6 +1097,460 @@ export class GitHubIntegration {
971
1097
  error: errorMessage,
972
1098
  })
973
1099
  return { success: false, error: errorMessage }
1100
+ } finally {
1101
+ this.invalidateCache('kanban:')
1102
+ }
1103
+ }
1104
+
1105
+ // ==========================================================================
1106
+ // GitHub Milestones Methods
1107
+ // ==========================================================================
1108
+
1109
+ /**
1110
+ * List milestones for a repository
1111
+ */
1112
+ async getMilestones(
1113
+ owner: string,
1114
+ repo: string,
1115
+ state: 'open' | 'closed' | 'all' = 'open',
1116
+ limit = 20
1117
+ ): Promise<GitHubMilestone[]> {
1118
+ if (!this.octokit) {
1119
+ return []
1120
+ }
1121
+
1122
+ const cacheKey = `milestones:${owner}:${repo}:${state}:${String(limit)}`
1123
+ const cached = this.getCached<GitHubMilestone[]>(cacheKey)
1124
+ if (cached) return cached
1125
+
1126
+ try {
1127
+ // GitHub REST API uses 'open' | 'closed' | 'all' for milestone state
1128
+ const apiState = state === 'all' ? undefined : state
1129
+ const response = await this.octokit.issues.listMilestones({
1130
+ owner,
1131
+ repo,
1132
+ state: apiState,
1133
+ per_page: limit,
1134
+ sort: 'due_on',
1135
+ direction: 'asc',
1136
+ })
1137
+
1138
+ const result = response.data.map((ms) => ({
1139
+ number: ms.number,
1140
+ title: ms.title,
1141
+ description: ms.description ?? null,
1142
+ state: ms.state === 'open' ? ('open' as const) : ('closed' as const),
1143
+ url: ms.html_url,
1144
+ dueOn: ms.due_on ?? null,
1145
+ openIssues: ms.open_issues,
1146
+ closedIssues: ms.closed_issues,
1147
+ createdAt: ms.created_at,
1148
+ updatedAt: ms.updated_at,
1149
+ creator: ms.creator?.login ?? null,
1150
+ }))
1151
+
1152
+ this.setCache(cacheKey, result)
1153
+ return result
1154
+ } catch (error) {
1155
+ logger.error('Failed to get milestones', {
1156
+ module: 'GitHub',
1157
+ error: error instanceof Error ? error.message : String(error),
1158
+ })
1159
+ return []
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Get a single milestone by number
1165
+ */
1166
+ async getMilestone(
1167
+ owner: string,
1168
+ repo: string,
1169
+ milestoneNumber: number
1170
+ ): Promise<GitHubMilestone | null> {
1171
+ if (!this.octokit) {
1172
+ return null
1173
+ }
1174
+
1175
+ const cacheKey = `milestone:${owner}:${repo}:${String(milestoneNumber)}`
1176
+ const cached = this.getCached<GitHubMilestone | null>(cacheKey)
1177
+ if (cached !== undefined) return cached
1178
+
1179
+ try {
1180
+ const response = await this.octokit.issues.getMilestone({
1181
+ owner,
1182
+ repo,
1183
+ milestone_number: milestoneNumber,
1184
+ })
1185
+
1186
+ const ms = response.data
1187
+ const milestone: GitHubMilestone = {
1188
+ number: ms.number,
1189
+ title: ms.title,
1190
+ description: ms.description ?? null,
1191
+ state: ms.state === 'open' ? 'open' : 'closed',
1192
+ url: ms.html_url,
1193
+ dueOn: ms.due_on ?? null,
1194
+ openIssues: ms.open_issues,
1195
+ closedIssues: ms.closed_issues,
1196
+ createdAt: ms.created_at,
1197
+ updatedAt: ms.updated_at,
1198
+ creator: ms.creator?.login ?? null,
1199
+ }
1200
+
1201
+ this.setCache(cacheKey, milestone)
1202
+ return milestone
1203
+ } catch (error) {
1204
+ logger.error('Failed to get milestone', {
1205
+ module: 'GitHub',
1206
+ entityId: milestoneNumber,
1207
+ error: error instanceof Error ? error.message : String(error),
1208
+ })
1209
+ return null
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Create a new milestone
1215
+ */
1216
+ async createMilestone(
1217
+ owner: string,
1218
+ repo: string,
1219
+ title: string,
1220
+ description?: string,
1221
+ dueOn?: string
1222
+ ): Promise<GitHubMilestone | null> {
1223
+ if (!this.octokit) {
1224
+ logger.error('Cannot create milestone: GitHub API not available', {
1225
+ module: 'GitHub',
1226
+ })
1227
+ return null
1228
+ }
1229
+
1230
+ try {
1231
+ const response = await this.octokit.issues.createMilestone({
1232
+ owner,
1233
+ repo,
1234
+ title,
1235
+ description,
1236
+ due_on: dueOn,
1237
+ })
1238
+
1239
+ const ms = response.data
1240
+
1241
+ logger.info('Created GitHub milestone', {
1242
+ module: 'GitHub',
1243
+ entityId: ms.number,
1244
+ context: { title, owner, repo },
1245
+ })
1246
+
1247
+ return {
1248
+ number: ms.number,
1249
+ title: ms.title,
1250
+ description: ms.description ?? null,
1251
+ state: ms.state === 'open' ? 'open' : 'closed',
1252
+ url: ms.html_url,
1253
+ dueOn: ms.due_on ?? null,
1254
+ openIssues: ms.open_issues,
1255
+ closedIssues: ms.closed_issues,
1256
+ createdAt: ms.created_at,
1257
+ updatedAt: ms.updated_at,
1258
+ creator: ms.creator?.login ?? null,
1259
+ }
1260
+ } catch (error) {
1261
+ logger.error('Failed to create milestone', {
1262
+ module: 'GitHub',
1263
+ error: error instanceof Error ? error.message : String(error),
1264
+ context: { title, owner, repo },
1265
+ })
1266
+ return null
1267
+ } finally {
1268
+ this.invalidateCache(`milestones:${owner}:${repo}`)
1269
+ this.invalidateCache('context:')
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Update an existing milestone
1275
+ */
1276
+ async updateMilestone(
1277
+ owner: string,
1278
+ repo: string,
1279
+ milestoneNumber: number,
1280
+ updates: {
1281
+ title?: string
1282
+ description?: string
1283
+ dueOn?: string | null
1284
+ state?: 'open' | 'closed'
1285
+ }
1286
+ ): Promise<GitHubMilestone | null> {
1287
+ if (!this.octokit) {
1288
+ logger.error('Cannot update milestone: GitHub API not available', {
1289
+ module: 'GitHub',
1290
+ })
1291
+ return null
1292
+ }
1293
+
1294
+ try {
1295
+ const response = await this.octokit.issues.updateMilestone({
1296
+ owner,
1297
+ repo,
1298
+ milestone_number: milestoneNumber,
1299
+ title: updates.title,
1300
+ description: updates.description,
1301
+ due_on: updates.dueOn === null ? undefined : updates.dueOn,
1302
+ state: updates.state,
1303
+ })
1304
+
1305
+ const ms = response.data
1306
+
1307
+ logger.info('Updated GitHub milestone', {
1308
+ module: 'GitHub',
1309
+ entityId: milestoneNumber,
1310
+ context: { owner, repo, updates: Object.keys(updates) },
1311
+ })
1312
+
1313
+ return {
1314
+ number: ms.number,
1315
+ title: ms.title,
1316
+ description: ms.description ?? null,
1317
+ state: ms.state === 'open' ? 'open' : 'closed',
1318
+ url: ms.html_url,
1319
+ dueOn: ms.due_on ?? null,
1320
+ openIssues: ms.open_issues,
1321
+ closedIssues: ms.closed_issues,
1322
+ createdAt: ms.created_at,
1323
+ updatedAt: ms.updated_at,
1324
+ creator: ms.creator?.login ?? null,
1325
+ }
1326
+ } catch (error) {
1327
+ logger.error('Failed to update milestone', {
1328
+ module: 'GitHub',
1329
+ entityId: milestoneNumber,
1330
+ error: error instanceof Error ? error.message : String(error),
1331
+ })
1332
+ return null
1333
+ } finally {
1334
+ this.invalidateCache(`milestones:${owner}:${repo}`)
1335
+ this.invalidateCache(`milestone:${owner}:${repo}:${String(milestoneNumber)}`)
1336
+ this.invalidateCache('context:')
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Delete a milestone
1342
+ */
1343
+ async deleteMilestone(
1344
+ owner: string,
1345
+ repo: string,
1346
+ milestoneNumber: number
1347
+ ): Promise<{ success: boolean; error?: string }> {
1348
+ if (!this.octokit) {
1349
+ return { success: false, error: 'GitHub API not available' }
1350
+ }
1351
+
1352
+ try {
1353
+ await this.octokit.issues.deleteMilestone({
1354
+ owner,
1355
+ repo,
1356
+ milestone_number: milestoneNumber,
1357
+ })
1358
+
1359
+ logger.info('Deleted GitHub milestone', {
1360
+ module: 'GitHub',
1361
+ entityId: milestoneNumber,
1362
+ context: { owner, repo },
1363
+ })
1364
+
1365
+ return { success: true }
1366
+ } catch (error) {
1367
+ const errorMessage = error instanceof Error ? error.message : String(error)
1368
+ logger.error('Failed to delete milestone', {
1369
+ module: 'GitHub',
1370
+ entityId: milestoneNumber,
1371
+ error: errorMessage,
1372
+ })
1373
+ return { success: false, error: errorMessage }
1374
+ } finally {
1375
+ this.invalidateCache(`milestones:${owner}:${repo}`)
1376
+ this.invalidateCache(`milestone:${owner}:${repo}:${String(milestoneNumber)}`)
1377
+ this.invalidateCache('context:')
1378
+ }
1379
+ }
1380
+
1381
+ // ==========================================================================
1382
+ // Repository Insights/Traffic Methods
1383
+ // ==========================================================================
1384
+
1385
+ /**
1386
+ * Get a cached value with a custom TTL.
1387
+ * Used for traffic endpoints which change slowly (10-min TTL).
1388
+ */
1389
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is needed at call sites for type-safe casts
1390
+ private getCachedWithTtl<T>(key: string, ttlMs: number): T | undefined {
1391
+ const entry = this.apiCache.get(key)
1392
+ if (entry && Date.now() - entry.timestamp < ttlMs) {
1393
+ return entry.data as T
1394
+ }
1395
+ if (entry) {
1396
+ this.apiCache.delete(key)
1397
+ }
1398
+ return undefined
1399
+ }
1400
+
1401
+ /**
1402
+ * Get repository statistics (stars, forks, watchers).
1403
+ * Uses a single GET /repos/{owner}/{repo} API call.
1404
+ */
1405
+ async getRepoStats(owner: string, repo: string): Promise<RepoStats | null> {
1406
+ if (!this.octokit) {
1407
+ return null
1408
+ }
1409
+
1410
+ const cacheKey = `repostats:${owner}:${repo}`
1411
+ const cached = this.getCachedWithTtl<RepoStats>(cacheKey, TRAFFIC_CACHE_TTL_MS)
1412
+ if (cached) return cached
1413
+
1414
+ try {
1415
+ const response = await this.octokit.repos.get({ owner, repo })
1416
+ const data = response.data
1417
+
1418
+ const result: RepoStats = {
1419
+ stars: data.stargazers_count,
1420
+ forks: data.forks_count,
1421
+ watchers: data.subscribers_count,
1422
+ openIssues: data.open_issues_count,
1423
+ size: data.size,
1424
+ defaultBranch: data.default_branch,
1425
+ }
1426
+
1427
+ this.setCache(cacheKey, result)
1428
+ return result
1429
+ } catch (error) {
1430
+ logger.error('Failed to get repo stats', {
1431
+ module: 'GitHub',
1432
+ error: error instanceof Error ? error.message : String(error),
1433
+ context: { owner, repo },
1434
+ })
1435
+ return null
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Get aggregated traffic data (14-day rolling clones + views).
1441
+ * Combines GET /repos/{owner}/{repo}/traffic/clones and /traffic/views.
1442
+ * Requires push access to the repository.
1443
+ */
1444
+ async getTrafficData(owner: string, repo: string): Promise<TrafficData | null> {
1445
+ if (!this.octokit) {
1446
+ return null
1447
+ }
1448
+
1449
+ const cacheKey = `traffic:${owner}:${repo}`
1450
+ const cached = this.getCachedWithTtl<TrafficData>(cacheKey, TRAFFIC_CACHE_TTL_MS)
1451
+ if (cached) return cached
1452
+
1453
+ try {
1454
+ const [clonesRes, viewsRes] = await Promise.all([
1455
+ this.octokit.rest.repos.getClones({ owner, repo }),
1456
+ this.octokit.rest.repos.getViews({ owner, repo }),
1457
+ ])
1458
+
1459
+ const clonesDays = clonesRes.data.clones?.length ?? 0
1460
+ const viewsDays = viewsRes.data.views?.length ?? 0
1461
+
1462
+ const result: TrafficData = {
1463
+ clones: {
1464
+ total: clonesRes.data.count,
1465
+ unique: clonesRes.data.uniques,
1466
+ dailyAvg: clonesDays > 0 ? Math.round(clonesRes.data.count / clonesDays) : 0,
1467
+ },
1468
+ views: {
1469
+ total: viewsRes.data.count,
1470
+ unique: viewsRes.data.uniques,
1471
+ dailyAvg: viewsDays > 0 ? Math.round(viewsRes.data.count / viewsDays) : 0,
1472
+ },
1473
+ period: '14 days',
1474
+ }
1475
+
1476
+ this.setCache(cacheKey, result)
1477
+ return result
1478
+ } catch (error) {
1479
+ logger.error('Failed to get traffic data', {
1480
+ module: 'GitHub',
1481
+ error: error instanceof Error ? error.message : String(error),
1482
+ context: { owner, repo },
1483
+ })
1484
+ return null
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Get top referrer sources for the repository (14-day rolling).
1490
+ * Requires push access to the repository.
1491
+ */
1492
+ async getTopReferrers(owner: string, repo: string, limit = 5): Promise<TrafficReferrer[]> {
1493
+ if (!this.octokit) {
1494
+ return []
1495
+ }
1496
+
1497
+ const cacheKey = `referrers:${owner}:${repo}`
1498
+ const cached = this.getCachedWithTtl<TrafficReferrer[]>(cacheKey, TRAFFIC_CACHE_TTL_MS)
1499
+ if (cached) return cached.slice(0, limit)
1500
+
1501
+ try {
1502
+ const response = await this.octokit.rest.repos.getTopReferrers({ owner, repo })
1503
+
1504
+ const result: TrafficReferrer[] = response.data.map((r) => ({
1505
+ referrer: r.referrer,
1506
+ count: r.count,
1507
+ uniques: r.uniques,
1508
+ }))
1509
+
1510
+ this.setCache(cacheKey, result)
1511
+ return result.slice(0, limit)
1512
+ } catch (error) {
1513
+ logger.error('Failed to get top referrers', {
1514
+ module: 'GitHub',
1515
+ error: error instanceof Error ? error.message : String(error),
1516
+ context: { owner, repo },
1517
+ })
1518
+ return []
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * Get popular repository paths (14-day rolling).
1524
+ * Requires push access to the repository.
1525
+ */
1526
+ async getPopularPaths(owner: string, repo: string, limit = 5): Promise<PopularPath[]> {
1527
+ if (!this.octokit) {
1528
+ return []
1529
+ }
1530
+
1531
+ const cacheKey = `paths:${owner}:${repo}`
1532
+ const cached = this.getCachedWithTtl<PopularPath[]>(cacheKey, TRAFFIC_CACHE_TTL_MS)
1533
+ if (cached) return cached.slice(0, limit)
1534
+
1535
+ try {
1536
+ const response = await this.octokit.rest.repos.getTopPaths({ owner, repo })
1537
+
1538
+ const result: PopularPath[] = response.data.map((p) => ({
1539
+ path: p.path,
1540
+ title: p.title,
1541
+ count: p.count,
1542
+ uniques: p.uniques,
1543
+ }))
1544
+
1545
+ this.setCache(cacheKey, result)
1546
+ return result.slice(0, limit)
1547
+ } catch (error) {
1548
+ logger.error('Failed to get popular paths', {
1549
+ module: 'GitHub',
1550
+ error: error instanceof Error ? error.message : String(error),
1551
+ context: { owner, repo },
1552
+ })
1553
+ return []
974
1554
  }
975
1555
  }
976
1556
  }