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,293 @@
1
+ /**
2
+ * Tests for GitHub service error handling
3
+ */
4
+ import { describe, test, expect } from "bun:test";
5
+ import { shouldRetryGitHubError, withRetry } from "../../../src/utils/retry.ts";
6
+ import { createMockGitHubService } from "../mocks/github-service.ts";
7
+
8
+ describe("GitHub Error Handling", () => {
9
+ describe("shouldRetryGitHubError", () => {
10
+ test("should retry on rate limit (429)", () => {
11
+ const error = new Error("rate limit exceeded 429");
12
+ expect(shouldRetryGitHubError(error, 1)).toBe(true);
13
+ });
14
+
15
+ test("should retry on server errors (5xx)", () => {
16
+ expect(shouldRetryGitHubError(new Error("500 Internal Server Error"), 1)).toBe(true);
17
+ expect(shouldRetryGitHubError(new Error("502 Bad Gateway"), 1)).toBe(true);
18
+ expect(shouldRetryGitHubError(new Error("503 Service Unavailable"), 1)).toBe(true);
19
+ expect(shouldRetryGitHubError(new Error("504 Gateway Timeout"), 1)).toBe(true);
20
+ });
21
+
22
+ test("should retry on network errors", () => {
23
+ expect(shouldRetryGitHubError(new Error("network error"), 1)).toBe(true);
24
+ expect(shouldRetryGitHubError(new Error("ECONNRESET"), 1)).toBe(true);
25
+ expect(shouldRetryGitHubError(new Error("ETIMEDOUT"), 1)).toBe(true);
26
+ expect(shouldRetryGitHubError(new Error("fetch failed"), 1)).toBe(true);
27
+ });
28
+
29
+ test("should NOT retry on client errors (4xx except 429)", () => {
30
+ expect(shouldRetryGitHubError(new Error("401 Unauthorized"), 1)).toBe(false);
31
+ expect(shouldRetryGitHubError(new Error("403 Forbidden"), 1)).toBe(false);
32
+ expect(shouldRetryGitHubError(new Error("404 Not Found"), 1)).toBe(false);
33
+ expect(shouldRetryGitHubError(new Error("422 Unprocessable Entity"), 1)).toBe(false);
34
+ });
35
+
36
+ test("should NOT retry on authentication failures with detailed message", () => {
37
+ const shouldRetry = shouldRetryGitHubError(new Error("401 Unauthorized: Bad credentials"), 1);
38
+ expect(shouldRetry).toBe(false);
39
+ });
40
+
41
+ test("should NOT retry on 404 with detailed message", () => {
42
+ const shouldRetry = shouldRetryGitHubError(new Error("404 Not Found"), 1);
43
+ expect(shouldRetry).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("Rate Limiting Recovery", () => {
48
+ test("should retry on rate limit error", async () => {
49
+ let attempts = 0;
50
+
51
+ const operation = async () => {
52
+ attempts++;
53
+ if (attempts < 3) {
54
+ throw new Error("API rate limit exceeded (429)");
55
+ }
56
+ // Return a mock user on success
57
+ return { login: "testuser", name: "Test User", type: "User" as const };
58
+ };
59
+
60
+ const result = await withRetry(operation, {
61
+ shouldRetry: shouldRetryGitHubError,
62
+ maxAttempts: 3,
63
+ initialDelay: 100
64
+ });
65
+
66
+ expect(result).toBeDefined();
67
+ expect(result.login).toBe("testuser");
68
+ expect(attempts).toBe(3);
69
+ });
70
+
71
+ test("should handle rate limit with retry-after header", async () => {
72
+ let attempts = 0;
73
+
74
+ const operation = async () => {
75
+ attempts++;
76
+ throw new Error("rate limit exceeded 429");
77
+ };
78
+
79
+ await expect(
80
+ withRetry(operation, {
81
+ shouldRetry: shouldRetryGitHubError,
82
+ maxAttempts: 2,
83
+ initialDelay: 50
84
+ })
85
+ ).rejects.toThrow("Failed after 2 attempts");
86
+
87
+ expect(attempts).toBe(2);
88
+ });
89
+ });
90
+
91
+ describe("Authentication Failure Handling", () => {
92
+ test("should not retry on authentication failures", async () => {
93
+ let attempts = 0;
94
+ let retryCalls = 0;
95
+
96
+ const operation = async () => {
97
+ attempts++;
98
+ throw new Error("401 Unauthorized: Bad credentials");
99
+ };
100
+
101
+ // Test the retry function directly
102
+ const shouldRetry = shouldRetryGitHubError(new Error("401 Unauthorized: Bad credentials"), 1);
103
+ expect(shouldRetry).toBe(false);
104
+
105
+ try {
106
+ await withRetry(operation, {
107
+ shouldRetry: (error, attempt) => {
108
+ retryCalls++;
109
+ return shouldRetryGitHubError(error, attempt);
110
+ },
111
+ maxAttempts: 3,
112
+ initialDelay: 50
113
+ });
114
+ } catch (error: any) {
115
+ // Should fail after 1 attempt since 401 errors are not retryable
116
+ expect(retryCalls).toBe(1); // Only called once for the first error
117
+ }
118
+
119
+ expect(attempts).toBe(1);
120
+ });
121
+
122
+ test("should handle token expiration", async () => {
123
+ const githubService = createMockGitHubService({
124
+ hasToken: false,
125
+ token: null
126
+ });
127
+
128
+ expect(githubService.hasToken()).toBe(false);
129
+ expect(githubService.getToken()).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("Retry Logic with Different Error Types", () => {
134
+ test("should retry on server errors", async () => {
135
+ let attempts = 0;
136
+
137
+ const operation = async () => {
138
+ attempts++;
139
+ if (attempts < 2) {
140
+ throw new Error("500 Internal Server Error");
141
+ }
142
+ return { success: true };
143
+ };
144
+
145
+ const result = await withRetry(operation, {
146
+ shouldRetry: shouldRetryGitHubError,
147
+ maxAttempts: 3,
148
+ initialDelay: 50
149
+ });
150
+
151
+ expect(result).toEqual({ success: true });
152
+ expect(attempts).toBe(2);
153
+ });
154
+
155
+ test("should retry on network errors", async () => {
156
+ let attempts = 0;
157
+
158
+ const operation = async () => {
159
+ attempts++;
160
+ if (attempts < 2) {
161
+ throw new Error("ECONNRESET");
162
+ }
163
+ return { success: true };
164
+ };
165
+
166
+ const result = await withRetry(operation, {
167
+ shouldRetry: shouldRetryGitHubError,
168
+ maxAttempts: 3,
169
+ initialDelay: 50
170
+ });
171
+
172
+ expect(result).toEqual({ success: true });
173
+ expect(attempts).toBe(2);
174
+ });
175
+
176
+ test("should not retry on client errors", async () => {
177
+ // Test the retry function directly
178
+ const shouldRetry404 = shouldRetryGitHubError(new Error("404 Not Found"), 1);
179
+ expect(shouldRetry404).toBe(false);
180
+
181
+ let attempts = 0;
182
+ let retryCalls = 0;
183
+
184
+ const operation = async () => {
185
+ attempts++;
186
+ throw new Error("404 Not Found");
187
+ };
188
+
189
+ try {
190
+ await withRetry(operation, {
191
+ shouldRetry: (error, attempt) => {
192
+ retryCalls++;
193
+ return shouldRetryGitHubError(error, attempt);
194
+ },
195
+ maxAttempts: 3,
196
+ initialDelay: 50
197
+ });
198
+ } catch (error: any) {
199
+ // Should fail after 1 attempt since 404 errors are not retryable
200
+ expect(retryCalls).toBe(1); // Only called once for the first error
201
+ }
202
+
203
+ expect(attempts).toBe(1);
204
+ });
205
+ });
206
+
207
+ describe("Max Retry Exhaustion", () => {
208
+ test("should fail after max attempts", async () => {
209
+ let attempts = 0;
210
+
211
+ const operation = async () => {
212
+ attempts++;
213
+ throw new Error("503 Service Unavailable");
214
+ };
215
+
216
+ await expect(
217
+ withRetry(operation, {
218
+ shouldRetry: shouldRetryGitHubError,
219
+ maxAttempts: 3,
220
+ initialDelay: 50
221
+ })
222
+ ).rejects.toThrow("Failed after 3 attempts");
223
+
224
+ expect(attempts).toBe(3);
225
+ });
226
+
227
+ test("should include last error in retry error", async () => {
228
+ const lastError = new Error("504 Gateway Timeout");
229
+
230
+ const operation = async () => {
231
+ throw lastError;
232
+ };
233
+
234
+ try {
235
+ await withRetry(operation, {
236
+ shouldRetry: shouldRetryGitHubError,
237
+ maxAttempts: 2,
238
+ initialDelay: 50
239
+ });
240
+ } catch (error: any) {
241
+ expect(error.message).toContain("Failed after 2 attempts");
242
+ expect(error.lastError).toBe(lastError);
243
+ expect(error.attempts).toBe(2);
244
+ }
245
+ });
246
+
247
+ test("should use exponential backoff", async () => {
248
+ const delays: number[] = [];
249
+ let lastTime = Date.now();
250
+
251
+ const operation = async () => {
252
+ const now = Date.now();
253
+ delays.push(now - lastTime);
254
+ lastTime = now;
255
+ throw new Error("502 Bad Gateway");
256
+ };
257
+
258
+ await expect(
259
+ withRetry(operation, {
260
+ shouldRetry: shouldRetryGitHubError,
261
+ maxAttempts: 4,
262
+ initialDelay: 100,
263
+ backoffFactor: 2,
264
+ onRetry: (error, attempt, nextDelay) => {
265
+ // Verify exponential backoff
266
+ expect(nextDelay).toBe(100 * Math.pow(2, attempt - 1));
267
+ }
268
+ })
269
+ ).rejects.toThrow("Failed after 4 attempts");
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("Token Validation", () => {
275
+ test("should validate classic token format", () => {
276
+ // Classic tokens are 40 hex characters
277
+ const validClassic = "a".repeat(40);
278
+ expect(validClassic).toMatch(/^[a-f0-9]{40}$/);
279
+ });
280
+
281
+ test("should validate new PAT format", () => {
282
+ // New PATs start with ghp_
283
+ const validPAT = "ghp_" + "a".repeat(36);
284
+ expect(validPAT).toMatch(/^ghp_[a-zA-Z0-9]{36}$/);
285
+ });
286
+
287
+ test("should validate fine-grained PAT format", () => {
288
+ // Fine-grained PATs have specific format
289
+ const pattern = /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/;
290
+ const validFineGrained = "github_pat_" + "a".repeat(22) + "_" + "b".repeat(59);
291
+ expect(validFineGrained).toMatch(pattern);
292
+ });
293
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Tests for GitHub service implementation
3
+ * Tests for token validation and basic functionality
4
+ */
5
+
6
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
7
+ import { apiGitHubService } from "../../../src/services/github.ts";
8
+
9
+ describe("GitHub Service - apiGitHubService", () => {
10
+ let originalEnv: Record<string, string | undefined>;
11
+
12
+ beforeEach(() => {
13
+ originalEnv = { ...process.env };
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.env = originalEnv;
18
+ });
19
+
20
+ describe("Token handling", () => {
21
+ test("hasToken returns true when GITHUB_TOKEN is set", () => {
22
+ process.env.GITHUB_TOKEN = "ghp_test123";
23
+ expect(apiGitHubService.hasToken()).toBe(true);
24
+ });
25
+
26
+ test("hasToken returns true when GH_TOKEN is set", () => {
27
+ process.env.GH_TOKEN = "ghp_test123";
28
+ expect(apiGitHubService.hasToken()).toBe(true);
29
+ });
30
+
31
+ test("hasToken returns false when no token is set", () => {
32
+ delete process.env.GITHUB_TOKEN;
33
+ delete process.env.GH_TOKEN;
34
+ expect(apiGitHubService.hasToken()).toBe(false);
35
+ });
36
+
37
+ test("getToken returns GITHUB_TOKEN value", () => {
38
+ process.env.GITHUB_TOKEN = "ghp_test123";
39
+ expect(apiGitHubService.getToken()).toBe("ghp_test123");
40
+ });
41
+
42
+ test("getToken falls back to GH_TOKEN", () => {
43
+ process.env.GH_TOKEN = "ghp_fallback123";
44
+ expect(apiGitHubService.getToken()).toBe("ghp_fallback123");
45
+ });
46
+
47
+ test("getToken returns null when no token is set", () => {
48
+ delete process.env.GITHUB_TOKEN;
49
+ delete process.env.GH_TOKEN;
50
+ expect(apiGitHubService.getToken()).toBeNull();
51
+ });
52
+
53
+ test("getToken warns on invalid token format", () => {
54
+ const originalWarn = console.warn;
55
+ const warnings: string[] = [];
56
+ console.warn = (message: string) => warnings.push(message);
57
+
58
+ process.env.GITHUB_TOKEN = "invalid-token";
59
+ apiGitHubService.getToken();
60
+
61
+ expect(warnings.length).toBeGreaterThan(0);
62
+ expect(warnings[0]).toContain("GITHUB_TOKEN format appears invalid");
63
+
64
+ console.warn = originalWarn;
65
+ });
66
+ });
67
+
68
+ describe("Token format validation", () => {
69
+ test("validates classic 40-char hex tokens", () => {
70
+ const validToken = "a".repeat(40);
71
+ expect(validToken).toMatch(/^[a-f0-9]{40}$/);
72
+ });
73
+
74
+ test("validates ghp_ prefix tokens", () => {
75
+ const validToken = "ghp_" + "a".repeat(36);
76
+ expect(validToken).toMatch(/^ghp_[a-zA-Z0-9]{36}$/);
77
+ });
78
+
79
+ test("validates github_pat_ fine-grained tokens", () => {
80
+ const validToken = "github_pat_" + "a".repeat(22) + "_" + "b".repeat(59);
81
+ expect(validToken).toMatch(/^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/);
82
+ });
83
+
84
+ test("validates gho_ OAuth tokens", () => {
85
+ const validToken = "gho_" + "a".repeat(40);
86
+ expect(validToken).toMatch(/^gho_[a-zA-Z0-9]{35,40}$/);
87
+ });
88
+
89
+ test("validates ghs_ GitHub App tokens", () => {
90
+ const validToken = "ghs_" + "a".repeat(36);
91
+ expect(validToken).toMatch(/^ghs_[a-zA-Z0-9]{36}$/);
92
+ });
93
+
94
+ test("validates ghr_ refresh tokens", () => {
95
+ const validToken = "ghr_" + "a".repeat(36);
96
+ expect(validToken).toMatch(/^ghr_[a-zA-Z0-9]{36}$/);
97
+ });
98
+
99
+ test("rejects invalid formats", () => {
100
+ const invalidTokens = [
101
+ "invalid",
102
+ "123",
103
+ "ghp_" + "a".repeat(35), // Too short
104
+ "github_pat_" + "a".repeat(21) + "_" + "b".repeat(58), // Too short
105
+ ];
106
+
107
+ for (const token of invalidTokens) {
108
+ const patterns = [
109
+ /^ghp_[a-zA-Z0-9]{36}$/,
110
+ /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/,
111
+ /^gho_[a-zA-Z0-9]{35,40}$/,
112
+ /^ghs_[a-zA-Z0-9]{36}$/,
113
+ /^ghr_[a-zA-Z0-9]{36}$/,
114
+ /^[a-f0-9]{40}$/,
115
+ ];
116
+
117
+ const isValid = patterns.some(pattern => pattern.test(token));
118
+ expect(isValid).toBe(false);
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("Error handling", () => {
124
+ test("handles missing token in createRepo", async () => {
125
+ delete process.env.GITHUB_TOKEN;
126
+ delete process.env.GH_TOKEN;
127
+
128
+ const result = await apiGitHubService.createRepo({
129
+ name: "test-repo",
130
+ });
131
+
132
+ expect(result.success).toBe(false);
133
+ expect(result.error).toBe("GITHUB_TOKEN not set");
134
+ });
135
+ });
136
+
137
+ describe("Repository operations", () => {
138
+ test("createRepo handles missing token", async () => {
139
+ delete process.env.GITHUB_TOKEN;
140
+ delete process.env.GH_TOKEN;
141
+
142
+ const result = await apiGitHubService.createRepo({
143
+ name: "test-repo",
144
+ });
145
+
146
+ expect(result.success).toBe(false);
147
+ expect(result.error).toBe("GITHUB_TOKEN not set");
148
+ });
149
+
150
+ test("archiveRepo handles missing token", async () => {
151
+ delete process.env.GITHUB_TOKEN;
152
+ delete process.env.GH_TOKEN;
153
+
154
+ const result = await apiGitHubService.archiveRepo("user/repo");
155
+
156
+ expect(result.success).toBe(false);
157
+ expect(result.error).toBe("GITHUB_TOKEN not set");
158
+ });
159
+
160
+ test("unarchiveRepo handles missing token", async () => {
161
+ delete process.env.GITHUB_TOKEN;
162
+ delete process.env.GH_TOKEN;
163
+
164
+ const result = await apiGitHubService.unarchiveRepo("user/repo");
165
+
166
+ expect(result.success).toBe(false);
167
+ expect(result.error).toBe("GITHUB_TOKEN not set");
168
+ });
169
+
170
+ test("deleteRepo handles missing token", async () => {
171
+ delete process.env.GITHUB_TOKEN;
172
+ delete process.env.GH_TOKEN;
173
+
174
+ const result = await apiGitHubService.deleteRepo("user/repo");
175
+
176
+ expect(result.success).toBe(false);
177
+ expect(result.error).toBe("GITHUB_TOKEN not set");
178
+ });
179
+ });
180
+
181
+ describe("cloneRepo", () => {
182
+ test("cloneRepo handles missing token", async () => {
183
+ delete process.env.GITHUB_TOKEN;
184
+ delete process.env.GH_TOKEN;
185
+
186
+ const result = await apiGitHubService.cloneRepo(
187
+ {
188
+ fullName: "user/repo",
189
+ sshUrl: "git@github.com:user/repo.git",
190
+ cloneUrl: "https://github.com/user/repo.git",
191
+ } as any,
192
+ { targetDir: "/tmp/clone-test" }
193
+ );
194
+
195
+ // cloneRepo doesn't require token, should work
196
+ expect(result.operation).toBe("clone");
197
+ expect(typeof result.duration).toBe("number");
198
+ });
199
+ });
200
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Tests for state action creators
3
+ *
4
+ * NOTE: This file must NOT have any module mocks as it tests real implementations.
5
+ * We restore any mocks at the start to ensure isolation.
6
+ */
7
+
8
+ import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
9
+
10
+ // Restore any mocks from other test files before importing real modules
11
+ mock.restore();
12
+
13
+ import {
14
+ setProjects,
15
+ setLoading,
16
+ setError,
17
+ setMessage,
18
+ moveCursor,
19
+ toggleSelection,
20
+ selectAll,
21
+ deselectAll,
22
+ setFilter,
23
+ setSort,
24
+ cycleSort,
25
+ setMode,
26
+ startAction,
27
+ endAction,
28
+ setScrollOffset,
29
+ updateProject,
30
+ } from "../../../src/state/actions.ts";
31
+ import { createMockProject } from "../mocks/index.ts";
32
+
33
+ describe("state action creators", () => {
34
+ describe("setProjects", () => {
35
+ test("creates SET_PROJECTS action", () => {
36
+ const projects = [createMockProject(), createMockProject()];
37
+ const action = setProjects(projects);
38
+
39
+ expect(action.type).toBe("SET_PROJECTS");
40
+ expect(action.payload).toBe(projects);
41
+ });
42
+ });
43
+
44
+ describe("setLoading", () => {
45
+ test("creates SET_LOADING action with true", () => {
46
+ const action = setLoading(true);
47
+
48
+ expect(action.type).toBe("SET_LOADING");
49
+ expect(action.payload).toBe(true);
50
+ });
51
+
52
+ test("creates SET_LOADING action with false", () => {
53
+ const action = setLoading(false);
54
+
55
+ expect(action.type).toBe("SET_LOADING");
56
+ expect(action.payload).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("setError", () => {
61
+ test("creates SET_ERROR action with error message", () => {
62
+ const action = setError("Something went wrong");
63
+
64
+ expect(action.type).toBe("SET_ERROR");
65
+ expect(action.payload).toBe("Something went wrong");
66
+ });
67
+
68
+ test("creates SET_ERROR action with null", () => {
69
+ const action = setError(null);
70
+
71
+ expect(action.type).toBe("SET_ERROR");
72
+ expect(action.payload).toBe(null);
73
+ });
74
+ });
75
+
76
+ describe("setMessage", () => {
77
+ test("creates SET_MESSAGE action", () => {
78
+ const action = setMessage("Operation successful");
79
+
80
+ expect(action.type).toBe("SET_MESSAGE");
81
+ expect(action.payload).toBe("Operation successful");
82
+ });
83
+ });
84
+
85
+ describe("moveCursor", () => {
86
+ test("creates MOVE_CURSOR action", () => {
87
+ const action = moveCursor(5);
88
+
89
+ expect(action.type).toBe("MOVE_CURSOR");
90
+ expect(action.payload).toBe(5);
91
+ });
92
+ });
93
+
94
+ describe("toggleSelection", () => {
95
+ test("creates TOGGLE_SELECTION action", () => {
96
+ const action = toggleSelection(3);
97
+
98
+ expect(action.type).toBe("TOGGLE_SELECTION");
99
+ expect(action.payload).toBe(3);
100
+ });
101
+ });
102
+
103
+ describe("selectAll", () => {
104
+ test("creates SELECT_ALL action", () => {
105
+ const action = selectAll();
106
+
107
+ expect(action.type).toBe("SELECT_ALL");
108
+ });
109
+ });
110
+
111
+ describe("deselectAll", () => {
112
+ test("creates DESELECT_ALL action", () => {
113
+ const action = deselectAll();
114
+
115
+ expect(action.type).toBe("DESELECT_ALL");
116
+ });
117
+ });
118
+
119
+ describe("setFilter", () => {
120
+ test("creates SET_FILTER action", () => {
121
+ const action = setFilter("my-project");
122
+
123
+ expect(action.type).toBe("SET_FILTER");
124
+ expect(action.payload).toBe("my-project");
125
+ });
126
+ });
127
+
128
+ describe("setSort", () => {
129
+ test("creates SET_SORT action", () => {
130
+ const action = setSort("name", "asc");
131
+
132
+ expect(action.type).toBe("SET_SORT");
133
+ expect(action.payload).toEqual({ by: "name", direction: "asc" });
134
+ });
135
+
136
+ test("creates SET_SORT action with status desc", () => {
137
+ const action = setSort("status", "desc");
138
+
139
+ expect(action.type).toBe("SET_SORT");
140
+ expect(action.payload).toEqual({ by: "status", direction: "desc" });
141
+ });
142
+ });
143
+
144
+ describe("cycleSort", () => {
145
+ test("creates CYCLE_SORT action", () => {
146
+ const action = cycleSort();
147
+
148
+ expect(action.type).toBe("CYCLE_SORT");
149
+ });
150
+ });
151
+
152
+ describe("setMode", () => {
153
+ test("creates SET_MODE action for normal", () => {
154
+ const action = setMode("normal");
155
+
156
+ expect(action.type).toBe("SET_MODE");
157
+ expect(action.payload).toBe("normal");
158
+ });
159
+
160
+ test("creates SET_MODE action for filter", () => {
161
+ const action = setMode("filter");
162
+
163
+ expect(action.type).toBe("SET_MODE");
164
+ expect(action.payload).toBe("filter");
165
+ });
166
+
167
+ test("creates SET_MODE action for help", () => {
168
+ const action = setMode("help");
169
+
170
+ expect(action.type).toBe("SET_MODE");
171
+ expect(action.payload).toBe("help");
172
+ });
173
+ });
174
+
175
+ describe("startAction", () => {
176
+ test("creates START_ACTION action", () => {
177
+ const action = startAction("Pushing 5 projects");
178
+
179
+ expect(action.type).toBe("START_ACTION");
180
+ expect(action.payload).toBe("Pushing 5 projects");
181
+ });
182
+ });
183
+
184
+ describe("endAction", () => {
185
+ test("creates END_ACTION action", () => {
186
+ const action = endAction();
187
+
188
+ expect(action.type).toBe("END_ACTION");
189
+ });
190
+ });
191
+
192
+ describe("setScrollOffset", () => {
193
+ test("creates SET_SCROLL_OFFSET action", () => {
194
+ const action = setScrollOffset(10);
195
+
196
+ expect(action.type).toBe("SET_SCROLL_OFFSET");
197
+ expect(action.payload).toBe(10);
198
+ });
199
+ });
200
+
201
+ describe("updateProject", () => {
202
+ test("creates UPDATE_PROJECT action", () => {
203
+ const action = updateProject("project-123", { name: "new-name" });
204
+
205
+ expect(action.type).toBe("UPDATE_PROJECT");
206
+ expect(action.payload).toEqual({
207
+ id: "project-123",
208
+ updates: { name: "new-name" },
209
+ });
210
+ });
211
+ });
212
+ });
213
+
214
+ // Restore all mocks after all tests complete
215
+ afterAll(() => {
216
+ mock.restore();
217
+ });