gitforest 0.1.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 (256) hide show
  1. package/.bunignore +7 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/CLAUDE.md +111 -0
  4. package/CONTRIBUTING.md +145 -0
  5. package/README.md +168 -0
  6. package/bun.lock +267 -0
  7. package/bunfig.toml +15 -0
  8. package/cli +0 -0
  9. package/config/gitforest.example.yaml +94 -0
  10. package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
  11. package/docs/ai/VERIFICATION_REPORT.md +87 -0
  12. package/docs/ai/architecture.md +169 -0
  13. package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
  14. package/docs/ai/checks/check-2025-12-02.md +55 -0
  15. package/docs/ai/checks/test-verification-report.md +85 -0
  16. package/docs/ai/implementation-guide.md +776 -0
  17. package/docs/ai/research/gitty-codebase-analysis.md +221 -0
  18. package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
  19. package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
  20. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
  21. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
  22. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
  23. package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
  24. package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
  25. package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
  26. package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
  27. package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
  28. package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
  29. package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
  30. package/docs/ai/tickets/TASK-sitrep.md +28 -0
  31. package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
  32. package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
  33. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
  34. package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
  35. package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
  36. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
  37. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
  38. package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
  39. package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
  40. package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
  41. package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
  42. package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
  43. package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
  44. package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
  45. package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
  46. package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
  47. package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
  48. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
  49. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
  50. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
  51. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
  52. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
  53. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
  54. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
  55. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
  56. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
  57. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
  58. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
  59. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
  60. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
  61. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
  62. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
  63. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
  64. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
  65. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
  66. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
  67. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
  68. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
  69. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
  70. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
  71. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
  72. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
  73. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
  74. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
  75. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
  76. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
  77. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
  78. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
  79. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
  80. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
  81. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
  82. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
  83. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
  84. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
  85. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
  86. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
  87. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
  88. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
  89. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
  90. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
  91. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
  92. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
  93. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
  94. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
  95. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
  96. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
  97. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
  98. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
  99. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
  100. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
  101. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
  102. package/docs/ai/tkt-001-fix-database-error.md +217 -0
  103. package/docs/ai/ui-enhancement-plan.md +562 -0
  104. package/package.json +50 -0
  105. package/src/app.tsx +43 -0
  106. package/src/cli/config.ts +94 -0
  107. package/src/cli/formatters.ts +632 -0
  108. package/src/cli/index.ts +583 -0
  109. package/src/components/CloneDialog.tsx +137 -0
  110. package/src/components/ColumnHeader.tsx +128 -0
  111. package/src/components/CommandPalette.tsx +120 -0
  112. package/src/components/ConfirmDialog.tsx +105 -0
  113. package/src/components/ErrorBoundary.tsx +128 -0
  114. package/src/components/FilterBar.tsx +71 -0
  115. package/src/components/FilterOptionsOverlay.tsx +131 -0
  116. package/src/components/HelpOverlay.tsx +120 -0
  117. package/src/components/Layout.tsx +379 -0
  118. package/src/components/MarkdownRenderer.tsx +127 -0
  119. package/src/components/ProgressBar.tsx +53 -0
  120. package/src/components/ProjectItem.tsx +143 -0
  121. package/src/components/ProjectList.tsx +90 -0
  122. package/src/components/RepoDetailModal.tsx +367 -0
  123. package/src/components/StatusBar.tsx +188 -0
  124. package/src/components/UnifiedProjectItem.tsx +436 -0
  125. package/src/components/ViewModeIndicator.tsx +37 -0
  126. package/src/components/onboarding/CompleteStep.tsx +82 -0
  127. package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
  128. package/src/components/onboarding/DirectoriesStep.tsx +847 -0
  129. package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
  130. package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
  131. package/src/components/onboarding/OnboardingWizard.tsx +130 -0
  132. package/src/components/onboarding/WelcomeStep.tsx +69 -0
  133. package/src/config/loader.ts +263 -0
  134. package/src/config/onboarding.ts +67 -0
  135. package/src/constants.ts +96 -0
  136. package/src/db/index.ts +147 -0
  137. package/src/db/schema.ts +70 -0
  138. package/src/git/commands.ts +283 -0
  139. package/src/git/index.ts +2 -0
  140. package/src/git/operations.ts +93 -0
  141. package/src/git/service.ts +539 -0
  142. package/src/git/status.ts +84 -0
  143. package/src/git/types.ts +5 -0
  144. package/src/github/auth.ts +311 -0
  145. package/src/github/cache.ts +231 -0
  146. package/src/github/cli.ts +22 -0
  147. package/src/github/unified.ts +415 -0
  148. package/src/hooks/useBackgroundFetch.ts +76 -0
  149. package/src/hooks/useConfirmDialogActions.ts +120 -0
  150. package/src/hooks/useKeyBindings.ts +656 -0
  151. package/src/hooks/useProjects.ts +47 -0
  152. package/src/hooks/useUnifiedRepos.ts +317 -0
  153. package/src/index.tsx +494 -0
  154. package/src/operations/batch.ts +280 -0
  155. package/src/operations/commands.ts +140 -0
  156. package/src/operations/index.ts +37 -0
  157. package/src/scanner/index.ts +424 -0
  158. package/src/scanner/markers.ts +43 -0
  159. package/src/scanner/submodules.ts +61 -0
  160. package/src/services/git.ts +484 -0
  161. package/src/services/github.ts +676 -0
  162. package/src/services/index.ts +28 -0
  163. package/src/services/types.ts +99 -0
  164. package/src/state/actions.ts +175 -0
  165. package/src/state/reducer.ts +294 -0
  166. package/src/state/store.tsx +216 -0
  167. package/src/state/types.ts +8 -0
  168. package/src/types/index.ts +383 -0
  169. package/src/ui/theme.ts +44 -0
  170. package/src/utils/array.ts +14 -0
  171. package/src/utils/debug.ts +38 -0
  172. package/src/utils/errors.ts +17 -0
  173. package/src/utils/index.ts +8 -0
  174. package/src/utils/markdown.ts +230 -0
  175. package/src/utils/project-utils.ts +129 -0
  176. package/src/utils/rate-limiter.ts +134 -0
  177. package/src/utils/retry.ts +147 -0
  178. package/src/utils/timeout.ts +56 -0
  179. package/test/integration/app.isolated.tsx +240 -0
  180. package/test/integration/cli-commands.test.ts +287 -0
  181. package/test/integration/cli-validation.test.ts +264 -0
  182. package/test/integration/git-operations.test.ts +218 -0
  183. package/test/integration/scanner.test.ts +228 -0
  184. package/test/preload.ts +18 -0
  185. package/test/unit/cli/commands.test.ts +13 -0
  186. package/test/unit/cli/formatters.test.ts +1116 -0
  187. package/test/unit/cli/github-commands.test.ts +12 -0
  188. package/test/unit/components/CloneDialog.test.tsx +240 -0
  189. package/test/unit/components/ColumnHeader.test.tsx +128 -0
  190. package/test/unit/components/CommandPalette.test.tsx +355 -0
  191. package/test/unit/components/ConfirmDialog.test.tsx +111 -0
  192. package/test/unit/components/ErrorBoundary.test.tsx +139 -0
  193. package/test/unit/components/FilterBar.test.tsx +43 -0
  194. package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
  195. package/test/unit/components/HelpOverlay.test.tsx +90 -0
  196. package/test/unit/components/Layout.test.tsx +328 -0
  197. package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
  198. package/test/unit/components/ProgressBar.test.tsx +138 -0
  199. package/test/unit/components/ProjectItem.test.tsx +182 -0
  200. package/test/unit/components/ProjectList.test.tsx +311 -0
  201. package/test/unit/components/RepoDetailModal.test.tsx +445 -0
  202. package/test/unit/components/StatusBar.test.tsx +112 -0
  203. package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
  204. package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
  205. package/test/unit/components/test-utils.tsx +63 -0
  206. package/test/unit/config/loader.test.ts +692 -0
  207. package/test/unit/db/database.test.ts +978 -0
  208. package/test/unit/db/index.test.ts +314 -0
  209. package/test/unit/fixtures/setup.ts +186 -0
  210. package/test/unit/git/commands-untested.test.ts +205 -0
  211. package/test/unit/git/commands.test.ts +269 -0
  212. package/test/unit/git/operations.test.ts +322 -0
  213. package/test/unit/git/status.test.ts +219 -0
  214. package/test/unit/github/auth.test.ts +317 -0
  215. package/test/unit/github/cache.test.ts +1028 -0
  216. package/test/unit/github/cli.test.ts +135 -0
  217. package/test/unit/github/unified.test.ts +1201 -0
  218. package/test/unit/graceful-shutdown.test.ts +83 -0
  219. package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
  220. package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
  221. package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
  222. package/test/unit/hooks/useProjects.test.tsx +186 -0
  223. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
  224. package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
  225. package/test/unit/mocks/config.ts +109 -0
  226. package/test/unit/mocks/git-service.ts +274 -0
  227. package/test/unit/mocks/github-service.ts +250 -0
  228. package/test/unit/mocks/index.ts +72 -0
  229. package/test/unit/mocks/project.ts +148 -0
  230. package/test/unit/mocks/state-mocks.ts +187 -0
  231. package/test/unit/mocks/unified.ts +169 -0
  232. package/test/unit/operations/batch.test.ts +216 -0
  233. package/test/unit/operations/commands.test.ts +550 -0
  234. package/test/unit/scanner/errors.test.ts +297 -0
  235. package/test/unit/scanner/index.test.ts +1011 -0
  236. package/test/unit/scanner/markers.test.ts +150 -0
  237. package/test/unit/scanner/submodules.test.ts +99 -0
  238. package/test/unit/services/git-errors.test.ts +190 -0
  239. package/test/unit/services/git.test.ts +442 -0
  240. package/test/unit/services/github-errors.test.ts +293 -0
  241. package/test/unit/services/github.test.ts +200 -0
  242. package/test/unit/state/actions.test.ts +217 -0
  243. package/test/unit/state/reducer.test.ts +745 -0
  244. package/test/unit/state/store.test.tsx +711 -0
  245. package/test/unit/types/commands.test.ts +220 -0
  246. package/test/unit/types/schema.test.ts +179 -0
  247. package/test/unit/utils/array.test.ts +73 -0
  248. package/test/unit/utils/debug.test.ts +23 -0
  249. package/test/unit/utils/errors.test.ts +295 -0
  250. package/test/unit/utils/markdown.test.ts +163 -0
  251. package/test/unit/utils/project-utils.test.ts +756 -0
  252. package/test/unit/utils/rate-limiter.test.ts +256 -0
  253. package/test/unit/utils/retry.test.ts +165 -0
  254. package/test/unit/utils/strip-ansi.ts +13 -0
  255. package/test/unit/utils/timeout.test.ts +93 -0
  256. package/tsconfig.json +29 -0
@@ -0,0 +1,1028 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { fetchGitHubReposWithCache } from "../../../src/github/cache.ts";
3
+ import type { GitHubRepoInfo } from "../../../src/types/index.ts";
4
+ import type { GitHubService } from "../../../src/services/github.ts";
5
+ import type { DbInstance } from "../../../src/db/index.ts";
6
+
7
+ const sampleRepo = {
8
+ id: 1,
9
+ name: "demo",
10
+ fullName: "owner/demo",
11
+ description: null,
12
+ htmlUrl: "",
13
+ sshUrl: "",
14
+ cloneUrl: "",
15
+ isPrivate: false,
16
+ isArchived: false,
17
+ isFork: false,
18
+ createdAt: "",
19
+ updatedAt: "",
20
+ pushedAt: "",
21
+ size: 0,
22
+ stargazersCount: 0,
23
+ forksCount: 0,
24
+ openIssuesCount: 0,
25
+ watchersCount: 0,
26
+ topics: [] as string[],
27
+ license: null as { name: string } | null,
28
+ hasIssues: true,
29
+ hasWiki: true,
30
+ hasDiscussions: false,
31
+ language: null as string | null,
32
+ defaultBranch: "main",
33
+ owner: { login: "owner", type: "User" as const },
34
+ };
35
+
36
+ const toInfo = (data: typeof sampleRepo): GitHubRepoInfo => ({
37
+ name: data.name,
38
+ fullName: data.fullName,
39
+ owner: data.owner.login,
40
+ description: data.description,
41
+ htmlUrl: data.htmlUrl,
42
+ sshUrl: data.sshUrl,
43
+ cloneUrl: data.cloneUrl,
44
+ isPrivate: data.isPrivate,
45
+ isArchived: data.isArchived,
46
+ isFork: data.isFork,
47
+ pushedAt: null,
48
+ updatedAt: null,
49
+ defaultBranch: data.defaultBranch,
50
+ language: data.language,
51
+ size: data.size,
52
+ stargazersCount: data.stargazersCount,
53
+ forksCount: data.forksCount,
54
+ openIssuesCount: data.openIssuesCount,
55
+ watchersCount: data.watchersCount,
56
+ topics: data.topics,
57
+ license: data.license?.name ?? null,
58
+ hasIssues: data.hasIssues,
59
+ hasWiki: data.hasWiki,
60
+ hasDiscussions: data.hasDiscussions,
61
+ });
62
+
63
+ const makeDbStub = (): DbInstance =>
64
+ ({
65
+ delete: () => ({ run: async () => {} }),
66
+ insert: () => ({ values: async () => {} }),
67
+ select: () => ({ from: () => ({ all: async () => [] }) }),
68
+ } as unknown as DbInstance);
69
+
70
+ describe("fetchGitHubReposWithCache token fallback", () => {
71
+ const originalGithubToken = process.env.GITHUB_TOKEN;
72
+ const originalGhToken = process.env.GH_TOKEN;
73
+
74
+ beforeEach(() => {
75
+ delete process.env.GITHUB_TOKEN;
76
+ delete process.env.GH_TOKEN;
77
+ });
78
+
79
+ afterEach(() => {
80
+ if (originalGithubToken !== undefined) {
81
+ process.env.GITHUB_TOKEN = originalGithubToken;
82
+ } else {
83
+ delete process.env.GITHUB_TOKEN;
84
+ }
85
+ if (originalGhToken !== undefined) {
86
+ process.env.GH_TOKEN = originalGhToken;
87
+ } else {
88
+ delete process.env.GH_TOKEN;
89
+ }
90
+ });
91
+
92
+ test("uses gh CLI token when env vars are missing", async () => {
93
+ let tokenProviderCalled = false;
94
+ const tokenProvider = async () => {
95
+ tokenProviderCalled = true;
96
+ process.env.GITHUB_TOKEN = "gh-from-cli";
97
+ return process.env.GITHUB_TOKEN;
98
+ };
99
+
100
+ const githubService: GitHubService = {
101
+ hasToken: () => !!process.env.GITHUB_TOKEN,
102
+ getToken: () => process.env.GITHUB_TOKEN ?? null,
103
+ // Methods below are unused in this test
104
+ getAuthenticatedUser: async () => {
105
+ throw new Error("not implemented");
106
+ },
107
+ getUserOrgs: async () => [],
108
+ getUserRepos: async () => [],
109
+ getOrgRepos: async () => [],
110
+ getAllRepos: async () => [sampleRepo],
111
+ getRepo: async () => {
112
+ throw new Error("not implemented");
113
+ },
114
+ searchRepos: async () => [],
115
+ createRepo: async () => ({ success: false }),
116
+ archiveRepo: async () => ({ success: false }),
117
+ unarchiveRepo: async () => ({ success: false }),
118
+ deleteRepo: async () => ({ success: false }),
119
+ cloneRepo: async () => ({ success: false, operation: "clone", projectPath: "" }),
120
+ };
121
+
122
+ const result = await fetchGitHubReposWithCache(
123
+ {},
124
+ 0,
125
+ {
126
+ githubService,
127
+ toGitHubRepoInfo: toInfo,
128
+ db: makeDbStub(),
129
+ tokenProvider,
130
+ }
131
+ );
132
+
133
+ expect(tokenProviderCalled).toBe(true);
134
+ expect(result.repos).toHaveLength(1);
135
+ expect(result.repos[0]?.name).toBe("demo");
136
+ expect(result.fromCache).toBe(false);
137
+ });
138
+
139
+ test("does not call gh CLI when env token exists", async () => {
140
+ process.env.GITHUB_TOKEN = "env-token";
141
+ let tokenProviderCalled = false;
142
+ const tokenProvider = async () => {
143
+ tokenProviderCalled = true;
144
+ return "gh-from-cli";
145
+ };
146
+
147
+ const githubService: GitHubService = {
148
+ hasToken: () => !!process.env.GITHUB_TOKEN,
149
+ getToken: () => process.env.GITHUB_TOKEN ?? null,
150
+ getAuthenticatedUser: async () => {
151
+ throw new Error("not implemented");
152
+ },
153
+ getUserOrgs: async () => [],
154
+ getUserRepos: async () => [],
155
+ getOrgRepos: async () => [],
156
+ getAllRepos: async () => [sampleRepo],
157
+ getRepo: async () => {
158
+ throw new Error("not implemented");
159
+ },
160
+ searchRepos: async () => [],
161
+ createRepo: async () => ({ success: false }),
162
+ archiveRepo: async () => ({ success: false }),
163
+ unarchiveRepo: async () => ({ success: false }),
164
+ deleteRepo: async () => ({ success: false }),
165
+ cloneRepo: async () => ({ success: false, operation: "clone", projectPath: "" }),
166
+ };
167
+
168
+ const result = await fetchGitHubReposWithCache(
169
+ {},
170
+ 0,
171
+ {
172
+ githubService,
173
+ toGitHubRepoInfo: toInfo,
174
+ db: makeDbStub(),
175
+ tokenProvider,
176
+ }
177
+ );
178
+
179
+ expect(tokenProviderCalled).toBe(false);
180
+ expect(result.repos).toHaveLength(1);
181
+ expect(result.repos[0]?.name).toBe("demo");
182
+ });
183
+ });
184
+
185
+ /**
186
+ * Tests for GitHub cache module
187
+ *
188
+ * Uses dependency injection instead of mock.module() for better test isolation
189
+ */
190
+
191
+ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
192
+ import { Database } from "bun:sqlite";
193
+ import { drizzle } from "drizzle-orm/bun-sqlite";
194
+ import * as schema from "../../../src/db/schema.ts";
195
+ import {
196
+ saveGitHubReposToCache,
197
+ loadGitHubReposFromCache,
198
+ fetchGitHubReposWithCache,
199
+ clearGitHubCache,
200
+ type CacheDeps
201
+ } from "../../../src/github/cache.ts";
202
+ import type { GitHubRepoInfo } from "../../../src/types/index.ts";
203
+ import type { DbInstance } from "../../../src/db/index.ts";
204
+ import type { GitHubService } from "../../../src/services/github.ts";
205
+ import { createMockGitHubRepoInfo } from "../mocks/unified.ts";
206
+
207
+ // Helper function to create multiple mock repos
208
+ function createMockRepos(count: number, prefix = "repo"): GitHubRepoInfo[] {
209
+ return Array.from({ length: count }, (_, i) =>
210
+ createMockGitHubRepoInfo({
211
+ name: `${prefix}-${i + 1}`,
212
+ fullName: `user/${prefix}-${i + 1}`,
213
+ })
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Create an in-memory database for testing
219
+ */
220
+ function createTestDb(): { db: DbInstance; sqliteDb: Database } {
221
+ const sqliteDb = new Database(":memory:");
222
+ sqliteDb.run("PRAGMA foreign_keys = ON");
223
+
224
+ // Create github_repos table
225
+ sqliteDb.run(`
226
+ CREATE TABLE IF NOT EXISTS github_repos (
227
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
228
+ name TEXT NOT NULL,
229
+ full_name TEXT NOT NULL UNIQUE,
230
+ owner TEXT NOT NULL,
231
+ description TEXT,
232
+ html_url TEXT,
233
+ ssh_url TEXT,
234
+ clone_url TEXT,
235
+ is_private INTEGER,
236
+ is_archived INTEGER,
237
+ is_fork INTEGER,
238
+ pushed_at INTEGER,
239
+ updated_at INTEGER,
240
+ default_branch TEXT,
241
+ language TEXT,
242
+ size INTEGER,
243
+ stargazers_count INTEGER,
244
+ forks_count INTEGER,
245
+ open_issues_count INTEGER,
246
+ watchers_count INTEGER,
247
+ topics TEXT,
248
+ license TEXT,
249
+ has_issues INTEGER,
250
+ has_wiki INTEGER,
251
+ has_discussions INTEGER,
252
+ last_fetched INTEGER
253
+ );
254
+ `);
255
+
256
+ const db = drizzle(sqliteDb, { schema });
257
+ return { db, sqliteDb };
258
+ }
259
+
260
+ /**
261
+ * Create a mock GitHub service for testing
262
+ */
263
+ function createMockGitHubService(overrides?: Partial<GitHubService>): GitHubService {
264
+ return {
265
+ hasToken: mock(() => true),
266
+ getToken: mock(() => "mock-token"),
267
+ getAuthenticatedUser: mock(() => Promise.resolve({ login: "testuser", id: 1, type: "User" as const })),
268
+ getUserOrgs: mock(() => Promise.resolve([])),
269
+ getUserRepos: mock(() => Promise.resolve([])),
270
+ getOrgRepos: mock(() => Promise.resolve([])),
271
+ getAllRepos: mock(() => Promise.resolve([])),
272
+ getRepo: mock(() => Promise.resolve({} as any)),
273
+ searchRepos: mock(() => Promise.resolve([])),
274
+ createRepo: mock(() => Promise.resolve({ success: true })),
275
+ archiveRepo: mock(() => Promise.resolve({ success: true })),
276
+ unarchiveRepo: mock(() => Promise.resolve({ success: true })),
277
+ deleteRepo: mock(() => Promise.resolve({ success: true })),
278
+ cloneRepo: mock(() => Promise.resolve({ success: true, projectPath: "", operation: "clone" as const })),
279
+ ...overrides,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Create a mock toGitHubRepoInfo function for testing
285
+ */
286
+ function createMockToGitHubRepoInfo(): (data: any) => GitHubRepoInfo {
287
+ return (data: any) => ({
288
+ name: data.name,
289
+ fullName: data.fullName,
290
+ owner: data.owner?.login || data.owner,
291
+ description: data.description,
292
+ htmlUrl: data.html_url || data.htmlUrl || "",
293
+ sshUrl: data.ssh_url || data.sshUrl || "",
294
+ cloneUrl: data.clone_url || data.cloneUrl || "",
295
+ isPrivate: data.is_private !== undefined ? Boolean(data.is_private) : Boolean(data.isPrivate),
296
+ isArchived: data.is_archived !== undefined ? Boolean(data.is_archived) : Boolean(data.isArchived),
297
+ isFork: data.is_fork !== undefined ? Boolean(data.is_fork) : Boolean(data.isFork),
298
+ pushedAt: data.pushed_at ? new Date(data.pushed_at) : data.pushedAt ? new Date(data.pushedAt) : null,
299
+ updatedAt: data.updated_at ? new Date(data.updated_at) : data.updatedAt ? new Date(data.updatedAt) : null,
300
+ defaultBranch: data.default_branch || data.defaultBranch || "main",
301
+ language: data.language,
302
+ size: data.size || 0,
303
+ stargazersCount: data.stargazers_count || data.stargazersCount || 0,
304
+ forksCount: data.forks_count || data.forksCount || 0,
305
+ openIssuesCount: data.open_issues_count || data.openIssuesCount || 0,
306
+ watchersCount: data.watchers_count || data.watchersCount || 0,
307
+ topics: data.topics || [],
308
+ license: data.license?.name || data.license || null,
309
+ hasIssues: data.has_issues !== undefined ? Boolean(data.has_issues) : Boolean(data.hasIssues),
310
+ hasWiki: data.has_wiki !== undefined ? Boolean(data.has_wiki) : Boolean(data.hasWiki),
311
+ hasDiscussions: data.has_discussions !== undefined ? Boolean(data.has_discussions) : Boolean(data.hasDiscussions),
312
+ });
313
+ }
314
+
315
+ describe("GitHub Cache", () => {
316
+ let sqliteDb: Database;
317
+ let db: DbInstance;
318
+
319
+ beforeEach(() => {
320
+ const testDb = createTestDb();
321
+ db = testDb.db;
322
+ sqliteDb = testDb.sqliteDb;
323
+ });
324
+
325
+ afterEach(() => {
326
+ sqliteDb.close();
327
+ });
328
+
329
+ describe("saveGitHubReposToCache", () => {
330
+ test("saves repos to cache successfully", async () => {
331
+ const repos = [
332
+ createMockGitHubRepoInfo({
333
+ name: "repo1",
334
+ fullName: "user/repo1",
335
+ topics: ["typescript", "react"]
336
+ }),
337
+ createMockGitHubRepoInfo({
338
+ name: "repo2",
339
+ fullName: "user/repo2",
340
+ topics: ["javascript", "node"]
341
+ }),
342
+ ];
343
+
344
+ await saveGitHubReposToCache(repos, { db });
345
+
346
+ const cached = db.select().from(schema.githubRepos).all();
347
+ expect(cached).toHaveLength(2);
348
+ expect(cached[0]?.name).toBe("repo1");
349
+ expect(cached[0]?.fullName).toBe("user/repo1");
350
+ expect(cached[0]?.topics).toBe(JSON.stringify(["typescript", "react"]));
351
+ expect(cached[1]?.name).toBe("repo2");
352
+ expect(cached[1]?.fullName).toBe("user/repo2");
353
+ expect(cached[1]?.topics).toBe(JSON.stringify(["javascript", "node"]));
354
+ });
355
+
356
+ test("clears existing cache before saving", async () => {
357
+ // Insert initial data
358
+ db.insert(schema.githubRepos).values({
359
+ name: "old-repo",
360
+ fullName: "user/old-repo",
361
+ owner: "user",
362
+ lastFetched: new Date(Date.now() - 86400000), // 1 day ago
363
+ }).run();
364
+
365
+ expect(db.select().from(schema.githubRepos).all()).toHaveLength(1);
366
+
367
+ // Save new repos
368
+ const repos = [createMockGitHubRepoInfo({ name: "new-repo" })];
369
+ await saveGitHubReposToCache(repos, { db });
370
+
371
+ const cached = db.select().from(schema.githubRepos).all();
372
+ expect(cached).toHaveLength(1);
373
+ expect(cached[0]?.name).toBe("new-repo");
374
+ });
375
+
376
+ test("handles empty repos array", async () => {
377
+ await saveGitHubReposToCache([], { db });
378
+
379
+ const cached = db.select().from(schema.githubRepos).all();
380
+ expect(cached).toHaveLength(0);
381
+ });
382
+
383
+ test("handles null/undefined fields gracefully", async () => {
384
+ const repos: GitHubRepoInfo[] = [{
385
+ name: "test-repo",
386
+ fullName: "user/test-repo",
387
+ owner: "user",
388
+ description: null,
389
+ htmlUrl: "",
390
+ sshUrl: "",
391
+ cloneUrl: "",
392
+ isPrivate: false,
393
+ isArchived: false,
394
+ isFork: false,
395
+ pushedAt: null,
396
+ updatedAt: null,
397
+ defaultBranch: "main",
398
+ language: null,
399
+ size: 0,
400
+ stargazersCount: 0,
401
+ forksCount: 0,
402
+ openIssuesCount: 0,
403
+ watchersCount: 0,
404
+ topics: undefined,
405
+ license: null,
406
+ hasIssues: false,
407
+ hasWiki: false,
408
+ hasDiscussions: false,
409
+ }];
410
+
411
+ await saveGitHubReposToCache(repos, { db });
412
+
413
+ const cached = db.select().from(schema.githubRepos).all();
414
+ expect(cached).toHaveLength(1);
415
+ expect(cached[0]?.topics).toBeNull();
416
+ });
417
+
418
+ test("handles database errors gracefully", async () => {
419
+ const repos = [createMockGitHubRepoInfo()];
420
+
421
+ // Should complete without throwing
422
+ await expect(saveGitHubReposToCache(repos, { db })).resolves.toBeUndefined();
423
+ });
424
+ });
425
+
426
+ describe("loadGitHubReposFromCache", () => {
427
+ test("loads repos from cache successfully", async () => {
428
+ // Insert test data
429
+ const now = new Date();
430
+ db.insert(schema.githubRepos).values([
431
+ {
432
+ name: "cached-repo1",
433
+ fullName: "user/cached-repo1",
434
+ owner: "user",
435
+ description: "Test repo 1",
436
+ htmlUrl: "https://github.com/user/cached-repo1",
437
+ sshUrl: "git@github.com:user/cached-repo1.git",
438
+ cloneUrl: "https://github.com/user/cached-repo1.git",
439
+ isPrivate: false,
440
+ isArchived: false,
441
+ isFork: false,
442
+ pushedAt: now,
443
+ updatedAt: now,
444
+ defaultBranch: "main",
445
+ language: "TypeScript",
446
+ size: 1024,
447
+ stargazersCount: 100,
448
+ forksCount: 20,
449
+ openIssuesCount: 5,
450
+ watchersCount: 50,
451
+ topics: JSON.stringify(["typescript", "react"]),
452
+ license: "MIT",
453
+ hasIssues: true,
454
+ hasWiki: true,
455
+ hasDiscussions: false,
456
+ lastFetched: now,
457
+ },
458
+ {
459
+ name: "cached-repo2",
460
+ fullName: "user/cached-repo2",
461
+ owner: "user",
462
+ description: "Test repo 2",
463
+ lastFetched: now,
464
+ }
465
+ ]).run();
466
+
467
+ const repos = await loadGitHubReposFromCache({ db });
468
+
469
+ expect(repos).toHaveLength(2);
470
+ expect(repos[0]?.name).toBe("cached-repo1");
471
+ expect(repos[0]?.fullName).toBe("user/cached-repo1");
472
+ expect(repos[0]?.topics).toEqual(["typescript", "react"]);
473
+ expect(repos[1]?.name).toBe("cached-repo2");
474
+ });
475
+
476
+ test("handles empty cache", async () => {
477
+ const repos = await loadGitHubReposFromCache({ db });
478
+ expect(repos).toEqual([]);
479
+ });
480
+
481
+ test("JSON parsing of topics field", async () => {
482
+ db.insert(schema.githubRepos).values({
483
+ name: "topic-repo",
484
+ fullName: "user/topic-repo",
485
+ owner: "user",
486
+ topics: JSON.stringify(["javascript", "node", "express"]),
487
+ lastFetched: new Date(),
488
+ }).run();
489
+
490
+ const repos = await loadGitHubReposFromCache({ db });
491
+ expect(repos[0]?.topics).toEqual(["javascript", "node", "express"]);
492
+ });
493
+
494
+ test("handles invalid JSON in topics field", async () => {
495
+ db.insert(schema.githubRepos).values({
496
+ name: "invalid-topic-repo",
497
+ fullName: "user/invalid-topic-repo",
498
+ owner: "user",
499
+ topics: "invalid-json",
500
+ lastFetched: new Date(),
501
+ }).run();
502
+
503
+ const repos = await loadGitHubReposFromCache({ db });
504
+ expect(repos[0]?.topics).toEqual([]);
505
+ });
506
+
507
+ test("handles null/undefined database fields", async () => {
508
+ db.insert(schema.githubRepos).values({
509
+ name: "null-fields-repo",
510
+ fullName: "user/null-fields-repo",
511
+ owner: "user",
512
+ htmlUrl: null,
513
+ sshUrl: undefined,
514
+ isPrivate: null,
515
+ isArchived: null,
516
+ isFork: null,
517
+ defaultBranch: null,
518
+ size: null,
519
+ stargazersCount: null,
520
+ forksCount: null,
521
+ openIssuesCount: null,
522
+ watchersCount: null,
523
+ topics: null,
524
+ license: null,
525
+ hasIssues: null,
526
+ hasWiki: null,
527
+ hasDiscussions: null,
528
+ lastFetched: new Date(),
529
+ }).run();
530
+
531
+ const repos = await loadGitHubReposFromCache({ db });
532
+ const repo = repos[0];
533
+
534
+ expect(repo?.htmlUrl).toBe("");
535
+ expect(repo?.sshUrl).toBe("");
536
+ expect(repo?.isPrivate).toBe(false);
537
+ expect(repo?.isArchived).toBe(false);
538
+ expect(repo?.isFork).toBe(false);
539
+ expect(repo?.defaultBranch).toBe("main");
540
+ expect(repo?.size).toBe(0);
541
+ expect(repo?.stargazersCount).toBe(0);
542
+ expect(repo?.forksCount).toBe(0);
543
+ expect(repo?.openIssuesCount).toBe(0);
544
+ expect(repo?.watchersCount).toBe(0);
545
+ expect(repo?.topics).toEqual([]);
546
+ expect(repo?.license).toBeNull();
547
+ expect(repo?.hasIssues).toBe(false);
548
+ expect(repo?.hasWiki).toBe(false);
549
+ expect(repo?.hasDiscussions).toBe(false);
550
+ });
551
+ });
552
+
553
+ describe("fetchGitHubReposWithCache", () => {
554
+ test("returns cached data when cache is fresh", async () => {
555
+ const now = new Date();
556
+ const cachedRepo = {
557
+ name: "fresh-cache-repo",
558
+ fullName: "user/fresh-cache-repo",
559
+ owner: "user",
560
+ lastFetched: now,
561
+ updatedAt: now,
562
+ };
563
+
564
+ db.insert(schema.githubRepos).values(cachedRepo).run();
565
+
566
+ const mockService = createMockGitHubService();
567
+ const result = await fetchGitHubReposWithCache({}, 300, {
568
+ db,
569
+ githubService: mockService,
570
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
571
+ });
572
+
573
+ expect(result.repos).toHaveLength(1);
574
+ expect(result.fromCache).toBe(true);
575
+ expect(result.repos[0]?.name).toBe("fresh-cache-repo");
576
+ expect(mockService.getAllRepos).not.toHaveBeenCalled();
577
+ });
578
+
579
+ test("fetches fresh data when cache is stale", async () => {
580
+ // Insert stale cache data (10 minutes ago)
581
+ const oldDate = new Date(Date.now() - 600000);
582
+ db.insert(schema.githubRepos).values({
583
+ name: "stale-cache-repo",
584
+ fullName: "user/stale-cache-repo",
585
+ owner: "user",
586
+ lastFetched: oldDate,
587
+ updatedAt: oldDate,
588
+ }).run();
589
+
590
+ // Mock getAllRepos to return empty array (simulating no new repos)
591
+ const mockService = createMockGitHubService({
592
+ getAllRepos: mock(() => Promise.resolve([])),
593
+ });
594
+
595
+ const result = await fetchGitHubReposWithCache({}, 300, {
596
+ db,
597
+ githubService: mockService,
598
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
599
+ });
600
+
601
+ // Since cache is stale, it should fetch fresh data
602
+ expect(result.fromCache).toBe(false);
603
+ expect(mockService.getAllRepos).toHaveBeenCalled();
604
+ });
605
+
606
+ test("returns error when no token", async () => {
607
+ const mockService = createMockGitHubService({
608
+ hasToken: mock(() => false),
609
+ });
610
+
611
+ const result = await fetchGitHubReposWithCache({}, 300, {
612
+ db,
613
+ githubService: mockService,
614
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
615
+ });
616
+
617
+ expect(result.repos).toEqual([]);
618
+ expect(result.fromCache).toBe(false);
619
+ expect(result.error).toBe("GITHUB_TOKEN not set");
620
+ expect(mockService.getAllRepos).not.toHaveBeenCalled();
621
+ });
622
+
623
+ test("fallback to stale cache on fetch error", async () => {
624
+ // Insert stale cache data
625
+ const staleDate = new Date(Date.now() - 600000); // 10 minutes ago
626
+ db.insert(schema.githubRepos).values({
627
+ name: "stale-cache-repo",
628
+ fullName: "user/stale-cache-repo",
629
+ owner: "user",
630
+ lastFetched: staleDate,
631
+ updatedAt: staleDate,
632
+ }).run();
633
+
634
+ // Mock fetch error
635
+ const mockService = createMockGitHubService({
636
+ getAllRepos: mock(() => Promise.reject(new Error("API Error"))),
637
+ });
638
+
639
+ const result = await fetchGitHubReposWithCache({}, 300, {
640
+ db,
641
+ githubService: mockService,
642
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
643
+ });
644
+
645
+ expect(result.repos).toHaveLength(1);
646
+ expect(result.fromCache).toBe(true);
647
+ expect(result.error).toBe("API Error");
648
+ expect(result.repos[0]?.name).toBe("stale-cache-repo");
649
+ });
650
+
651
+ test("returns empty array when fetch fails and no cache available", async () => {
652
+ const mockService = createMockGitHubService({
653
+ getAllRepos: mock(() => Promise.reject(new Error("Network Error"))),
654
+ });
655
+
656
+ const result = await fetchGitHubReposWithCache({}, 300, {
657
+ db,
658
+ githubService: mockService,
659
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
660
+ });
661
+
662
+ expect(result.repos).toEqual([]);
663
+ expect(result.fromCache).toBe(false);
664
+ expect(result.error).toBe("Network Error");
665
+ });
666
+
667
+ test("respects custom cache TTL", async () => {
668
+ // Insert data that's 2 minutes old (120 seconds)
669
+ const twoMinutesAgo = new Date(Date.now() - 120000);
670
+ db.insert(schema.githubRepos).values({
671
+ name: "ttl-test-repo",
672
+ fullName: "user/ttl-test-repo",
673
+ owner: "user",
674
+ lastFetched: twoMinutesAgo,
675
+ updatedAt: twoMinutesAgo,
676
+ }).run();
677
+
678
+ const mockService = createMockGitHubService({
679
+ getAllRepos: mock(() => Promise.resolve([])),
680
+ });
681
+
682
+ // With TTL of 300 seconds (5 minutes), cache should be fresh
683
+ let result = await fetchGitHubReposWithCache({}, 300, {
684
+ db,
685
+ githubService: mockService,
686
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
687
+ });
688
+ expect(result.fromCache).toBe(true);
689
+
690
+ // Reset mock call count
691
+ (mockService.getAllRepos as any).mockClear();
692
+
693
+ // With TTL of 60 seconds (1 minute), cache should be stale
694
+ result = await fetchGitHubReposWithCache({}, 60, {
695
+ db,
696
+ githubService: mockService,
697
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
698
+ });
699
+ expect(result.fromCache).toBe(false);
700
+ });
701
+
702
+ test("passes options to getAllRepos", async () => {
703
+ const mockService = createMockGitHubService({
704
+ getAllRepos: mock(() => Promise.resolve([])),
705
+ });
706
+
707
+ const options = {
708
+ includeOrgs: true,
709
+ includeArchived: false,
710
+ includeForks: true,
711
+ };
712
+
713
+ await fetchGitHubReposWithCache(options, 300, {
714
+ db,
715
+ githubService: mockService,
716
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
717
+ });
718
+
719
+ expect(mockService.getAllRepos).toHaveBeenCalledWith(options);
720
+ });
721
+ });
722
+
723
+ describe("Cache freshness check", () => {
724
+ test("correctly identifies fresh cache", async () => {
725
+ // Insert fresh data (1 minute ago)
726
+ const oneMinuteAgo = new Date(Date.now() - 60000);
727
+ db.insert(schema.githubRepos).values({
728
+ name: "fresh-repo",
729
+ fullName: "user/fresh-repo",
730
+ owner: "user",
731
+ lastFetched: oneMinuteAgo,
732
+ updatedAt: oneMinuteAgo,
733
+ }).run();
734
+
735
+ const mockService = createMockGitHubService();
736
+ const result = await fetchGitHubReposWithCache({}, 300, {
737
+ db,
738
+ githubService: mockService,
739
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
740
+ }); // 5 minute TTL
741
+
742
+ expect(result.fromCache).toBe(true);
743
+ });
744
+
745
+ test("correctly identifies stale cache", async () => {
746
+ // Insert stale data (10 minutes ago)
747
+ const tenMinutesAgo = new Date(Date.now() - 600000);
748
+ db.insert(schema.githubRepos).values({
749
+ name: "stale-repo",
750
+ fullName: "user/stale-repo",
751
+ owner: "user",
752
+ lastFetched: tenMinutesAgo,
753
+ updatedAt: tenMinutesAgo,
754
+ }).run();
755
+
756
+ const mockService = createMockGitHubService({
757
+ getAllRepos: mock(() => Promise.resolve([])),
758
+ });
759
+
760
+ const result = await fetchGitHubReposWithCache({}, 300, {
761
+ db,
762
+ githubService: mockService,
763
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
764
+ }); // 5 minute TTL
765
+
766
+ expect(result.fromCache).toBe(false);
767
+ });
768
+
769
+ test("handles null updatedAt as stale", async () => {
770
+ // Insert data with null updatedAt
771
+ db.insert(schema.githubRepos).values({
772
+ name: "null-date-repo",
773
+ fullName: "user/null-date-repo",
774
+ owner: "user",
775
+ lastFetched: new Date(),
776
+ updatedAt: null,
777
+ }).run();
778
+
779
+ const mockService = createMockGitHubService({
780
+ getAllRepos: mock(() => Promise.resolve([])),
781
+ });
782
+
783
+ const result = await fetchGitHubReposWithCache({}, 300, {
784
+ db,
785
+ githubService: mockService,
786
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
787
+ });
788
+
789
+ expect(result.fromCache).toBe(false);
790
+ });
791
+ });
792
+
793
+ describe("Additional fetchGitHubReposWithCache tests", () => {
794
+ test("returns cached data when fresh (within TTL)", async () => {
795
+ // Insert fresh cache data
796
+ const now = new Date();
797
+ db.insert(schema.githubRepos).values({
798
+ name: "fresh-cache-repo",
799
+ fullName: "user/fresh-cache-repo",
800
+ owner: "user",
801
+ lastFetched: now,
802
+ updatedAt: now,
803
+ }).run();
804
+
805
+ const mockService = createMockGitHubService();
806
+ const result = await fetchGitHubReposWithCache({}, 300, {
807
+ db,
808
+ githubService: mockService,
809
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
810
+ });
811
+
812
+ expect(result.repos).toHaveLength(1);
813
+ expect(result.fromCache).toBe(true);
814
+ expect(result.repos[0]?.name).toBe("fresh-cache-repo");
815
+ expect(mockService.getAllRepos).not.toHaveBeenCalled();
816
+ });
817
+
818
+ test("fetches fresh data when TTL is 0", async () => {
819
+ // Insert cache data
820
+ db.insert(schema.githubRepos).values({
821
+ name: "cache-repo",
822
+ fullName: "user/cache-repo",
823
+ owner: "user",
824
+ lastFetched: new Date(),
825
+ updatedAt: new Date(),
826
+ }).run();
827
+
828
+ // Mock getAllRepos to return new repos
829
+ const newRepos = createMockRepos(1, "fresh-repo");
830
+ const mockService = createMockGitHubService({
831
+ getAllRepos: mock(() => Promise.resolve(newRepos as any)),
832
+ });
833
+
834
+ const result = await fetchGitHubReposWithCache({}, 0, {
835
+ db,
836
+ githubService: mockService,
837
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
838
+ });
839
+
840
+ expect(result.fromCache).toBe(false);
841
+ expect(result.repos).toHaveLength(1);
842
+ expect(result.repos[0]?.name).toBe("fresh-repo-1");
843
+ expect(mockService.getAllRepos).toHaveBeenCalled();
844
+ });
845
+
846
+ test("saves fetched data to cache", async () => {
847
+ // Mock getAllRepos to return repos
848
+ const newRepos = createMockRepos(3, "saved-repo");
849
+ const mockService = createMockGitHubService({
850
+ getAllRepos: mock(() => Promise.resolve(newRepos as any)),
851
+ });
852
+
853
+ const result = await fetchGitHubReposWithCache({}, 300, {
854
+ db,
855
+ githubService: mockService,
856
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
857
+ });
858
+
859
+ expect(result.fromCache).toBe(false);
860
+ expect(result.repos).toHaveLength(3);
861
+
862
+ // Wait a bit for background save to complete
863
+ await new Promise(resolve => setTimeout(resolve, 100));
864
+
865
+ // Verify cache was updated
866
+ const cached = await loadGitHubReposFromCache({ db });
867
+ expect(cached).toHaveLength(3);
868
+ expect(cached[0]?.name).toBe("saved-repo-1");
869
+ });
870
+
871
+ test("returns error message when GitHub token not set", async () => {
872
+ const mockService = createMockGitHubService({
873
+ hasToken: mock(() => false),
874
+ });
875
+
876
+ const result = await fetchGitHubReposWithCache({}, 300, {
877
+ db,
878
+ githubService: mockService,
879
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
880
+ });
881
+
882
+ expect(result.repos).toEqual([]);
883
+ expect(result.fromCache).toBe(false);
884
+ expect(result.error).toBe("GITHUB_TOKEN not set");
885
+ expect(mockService.getAllRepos).not.toHaveBeenCalled();
886
+ });
887
+
888
+ test("handles API errors gracefully", async () => {
889
+ // Mock API error
890
+ const mockService = createMockGitHubService({
891
+ getAllRepos: mock(() => Promise.reject(new Error("API Rate Limit Exceeded"))),
892
+ });
893
+
894
+ const result = await fetchGitHubReposWithCache({}, 300, {
895
+ db,
896
+ githubService: mockService,
897
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
898
+ });
899
+
900
+ expect(result.repos).toEqual([]);
901
+ expect(result.fromCache).toBe(false);
902
+ expect(result.error).toBe("API Rate Limit Exceeded");
903
+ });
904
+
905
+ test("returns cached data on API failure if available", async () => {
906
+ // Insert stale cache data
907
+ const staleDate = new Date(Date.now() - 600000); // 10 minutes ago
908
+ db.insert(schema.githubRepos).values({
909
+ name: "stale-cache-repo",
910
+ fullName: "user/stale-cache-repo",
911
+ owner: "user",
912
+ lastFetched: staleDate,
913
+ updatedAt: staleDate,
914
+ }).run();
915
+
916
+ // Mock API error
917
+ const mockService = createMockGitHubService({
918
+ getAllRepos: mock(() => Promise.reject(new Error("Network Error"))),
919
+ });
920
+
921
+ const result = await fetchGitHubReposWithCache({}, 300, {
922
+ db,
923
+ githubService: mockService,
924
+ toGitHubRepoInfo: createMockToGitHubRepoInfo(),
925
+ });
926
+
927
+ expect(result.repos).toHaveLength(1);
928
+ expect(result.fromCache).toBe(true);
929
+ expect(result.error).toBe("Network Error");
930
+ expect(result.repos[0]?.name).toBe("stale-cache-repo");
931
+ });
932
+ });
933
+
934
+ describe("Additional loadGitHubReposFromCache tests", () => {
935
+ test("returns empty array when no cache", async () => {
936
+ const repos = await loadGitHubReposFromCache({ db });
937
+ expect(repos).toEqual([]);
938
+ });
939
+
940
+ test("returns cached repos when available", async () => {
941
+ const now = new Date();
942
+ db.insert(schema.githubRepos).values([
943
+ {
944
+ name: "repo1",
945
+ fullName: "user/repo1",
946
+ owner: "user",
947
+ lastFetched: now,
948
+ },
949
+ {
950
+ name: "repo2",
951
+ fullName: "user/repo2",
952
+ owner: "user",
953
+ lastFetched: now,
954
+ }
955
+ ]).run();
956
+
957
+ const repos = await loadGitHubReposFromCache({ db });
958
+
959
+ expect(repos).toHaveLength(2);
960
+ expect(repos[0]?.name).toBe("repo1");
961
+ expect(repos[1]?.name).toBe("repo2");
962
+ });
963
+
964
+ test("handles corrupted cache data", async () => {
965
+ // Insert data with corrupted topics JSON
966
+ db.insert(schema.githubRepos).values({
967
+ name: "corrupted-repo",
968
+ fullName: "user/corrupted-repo",
969
+ owner: "user",
970
+ topics: "{invalid json",
971
+ lastFetched: new Date(),
972
+ }).run();
973
+
974
+ // Should not throw and return empty topics array
975
+ const repos = await loadGitHubReposFromCache({ db });
976
+ expect(repos).toHaveLength(1);
977
+ expect(repos[0]?.topics).toEqual([]);
978
+ });
979
+ });
980
+
981
+ describe("Additional saveGitHubReposToCache tests", () => {
982
+ test("saves repos to database", async () => {
983
+ const repos = [
984
+ createMockGitHubRepoInfo({ name: "test-repo-1" }),
985
+ createMockGitHubRepoInfo({ name: "test-repo-2" }),
986
+ ];
987
+
988
+ await saveGitHubReposToCache(repos, { db });
989
+
990
+ const cached = db.select().from(schema.githubRepos).all();
991
+ expect(cached).toHaveLength(2);
992
+ expect(cached[0]?.name).toBe("test-repo-1");
993
+ expect(cached[1]?.name).toBe("test-repo-2");
994
+ });
995
+
996
+ test("updates existing cache entries", async () => {
997
+ // Insert initial data
998
+ db.insert(schema.githubRepos).values({
999
+ name: "old-repo",
1000
+ fullName: "user/old-repo",
1001
+ owner: "user",
1002
+ description: "Old description",
1003
+ lastFetched: new Date(Date.now() - 86400000), // 1 day ago
1004
+ }).run();
1005
+
1006
+ const newRepos = [
1007
+ createMockGitHubRepoInfo({
1008
+ name: "new-repo",
1009
+ description: "New description"
1010
+ }),
1011
+ ];
1012
+
1013
+ await saveGitHubReposToCache(newRepos, { db });
1014
+
1015
+ const cached = db.select().from(schema.githubRepos).all();
1016
+ expect(cached).toHaveLength(1);
1017
+ expect(cached[0]?.name).toBe("new-repo");
1018
+ expect(cached[0]?.description).toBe("New description");
1019
+ });
1020
+
1021
+ test("handles save failures gracefully", async () => {
1022
+ const repos = [createMockGitHubRepoInfo()];
1023
+
1024
+ // Should complete without throwing
1025
+ await expect(saveGitHubReposToCache(repos, { db })).resolves.toBeUndefined();
1026
+ });
1027
+ });
1028
+ });