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,256 @@
1
+ /**
2
+ * Tests for rate limiter utilities
3
+ */
4
+ import { describe, test, expect } from "bun:test";
5
+ import { Semaphore, RateLimiter, processBatch } from "../../../src/utils/rate-limiter.ts";
6
+
7
+ describe("Semaphore", () => {
8
+ test("should limit concurrent operations", async () => {
9
+ const semaphore = new Semaphore(2);
10
+ let concurrent = 0;
11
+ let maxConcurrent = 0;
12
+
13
+ const task = async () => {
14
+ await semaphore.acquire();
15
+ concurrent++;
16
+ maxConcurrent = Math.max(maxConcurrent, concurrent);
17
+ await new Promise(resolve => setTimeout(resolve, 50));
18
+ concurrent--;
19
+ semaphore.release();
20
+ };
21
+
22
+ await Promise.all([task(), task(), task(), task()]);
23
+
24
+ expect(maxConcurrent).toBeLessThanOrEqual(2);
25
+ });
26
+
27
+ test("should run function with automatic release", async () => {
28
+ const semaphore = new Semaphore(1);
29
+
30
+ const result = await semaphore.run(async () => {
31
+ return "success";
32
+ });
33
+
34
+ expect(result).toBe("success");
35
+ });
36
+
37
+ test("should release on error", async () => {
38
+ const semaphore = new Semaphore(1);
39
+
40
+ try {
41
+ await semaphore.run(async () => {
42
+ throw new Error("test error");
43
+ });
44
+ } catch {
45
+ // Expected
46
+ }
47
+
48
+ // Should be able to acquire again after error
49
+ const result = await semaphore.run(async () => "recovered");
50
+ expect(result).toBe("recovered");
51
+ });
52
+ });
53
+
54
+ describe("RateLimiter", () => {
55
+ test("should enforce minimum delay between operations", async () => {
56
+ const limiter = new RateLimiter(100);
57
+ const times: number[] = [];
58
+
59
+ for (let i = 0; i < 3; i++) {
60
+ await limiter.wait();
61
+ times.push(Date.now());
62
+ }
63
+
64
+ // Check delays between operations
65
+ for (let i = 1; i < times.length; i++) {
66
+ const delay = times[i]! - times[i-1]!;
67
+ expect(delay).toBeGreaterThanOrEqual(95); // Allow small timing variance
68
+ }
69
+ });
70
+ });
71
+
72
+ describe("processBatch", () => {
73
+ test("should process all items", async () => {
74
+ const items = [1, 2, 3, 4, 5];
75
+ const results = await processBatch(
76
+ items,
77
+ async (n) => n * 2,
78
+ { concurrency: 2 }
79
+ );
80
+
81
+ expect(results).toEqual([
82
+ { success: true, result: 2 },
83
+ { success: true, result: 4 },
84
+ { success: true, result: 6 },
85
+ { success: true, result: 8 },
86
+ { success: true, result: 10 }
87
+ ]);
88
+ });
89
+
90
+ test("should call progress callback", async () => {
91
+ const items = [1, 2, 3];
92
+ const progressCalls: Array<[number, number]> = [];
93
+
94
+ const results = await processBatch(
95
+ items,
96
+ async (n) => n,
97
+ {
98
+ concurrency: 1,
99
+ onProgress: (completed, total) => {
100
+ progressCalls.push([completed, total]);
101
+ }
102
+ }
103
+ );
104
+
105
+ expect(progressCalls).toEqual([[1, 3], [2, 3], [3, 3]]);
106
+ expect(results).toHaveLength(3);
107
+ expect(results.every(r => r.success)).toBe(true);
108
+ });
109
+
110
+ test("should handle errors with onError callback", async () => {
111
+ const items = [1, 2, 3];
112
+ const errors: Error[] = [];
113
+
114
+ const results = await processBatch(
115
+ items,
116
+ async (n) => {
117
+ if (n === 2) throw new Error("test error");
118
+ return n;
119
+ },
120
+ {
121
+ concurrency: 1,
122
+ onError: (err, _item) => {
123
+ errors.push(err);
124
+ return true; // Continue processing
125
+ }
126
+ }
127
+ );
128
+
129
+ expect(results).toEqual([
130
+ { success: true, result: 1 },
131
+ { success: false, error: new Error("test error") },
132
+ { success: true, result: 3 }
133
+ ]);
134
+ expect(errors).toHaveLength(1);
135
+ });
136
+
137
+ test("should respect concurrency limit", async () => {
138
+ let concurrent = 0;
139
+ let maxConcurrent = 0;
140
+
141
+ const results = await processBatch(
142
+ [1, 2, 3, 4, 5, 6],
143
+ async () => {
144
+ concurrent++;
145
+ maxConcurrent = Math.max(maxConcurrent, concurrent);
146
+ await new Promise(resolve => setTimeout(resolve, 20));
147
+ concurrent--;
148
+ },
149
+ { concurrency: 3 }
150
+ );
151
+
152
+ expect(maxConcurrent).toBeLessThanOrEqual(3);
153
+ expect(results).toHaveLength(6);
154
+ expect(results.every(r => r.success)).toBe(true);
155
+ });
156
+
157
+ test("should stop processing when onError returns false", async () => {
158
+ const items = [1, 2, 3, 4, 5];
159
+ const processed: number[] = [];
160
+ let errorThrown = false;
161
+
162
+ try {
163
+ await processBatch(
164
+ items,
165
+ async (n) => {
166
+ processed.push(n);
167
+ if (n === 3) {
168
+ errorThrown = true;
169
+ throw new Error("stop error");
170
+ }
171
+ return n;
172
+ },
173
+ {
174
+ concurrency: 1,
175
+ onError: (err, _item) => {
176
+ return false; // Stop processing
177
+ }
178
+ }
179
+ );
180
+ // Should not reach here
181
+ throw new Error("Expected processBatch to throw");
182
+ } catch (error) {
183
+ expect((error as Error).message).toBe("stop error");
184
+ // With concurrency 1, items are processed sequentially
185
+ // So we should have processed items 1, 2, and 3 before stopping
186
+ expect(errorThrown).toBe(true);
187
+ expect(processed).toContain(1);
188
+ expect(processed).toContain(2);
189
+ expect(processed).toContain(3);
190
+ // Due to Promise.all creating all promises upfront, some later items
191
+ // might have started processing before the error was thrown
192
+ }
193
+ });
194
+
195
+ test("should capture error details in failed results", async () => {
196
+ const items = [1, 2, 3];
197
+ const customError = new Error("custom error message");
198
+
199
+ const results = await processBatch(
200
+ items,
201
+ async (n) => {
202
+ if (n === 2) throw customError;
203
+ return n * 10;
204
+ },
205
+ {
206
+ concurrency: 1,
207
+ onError: (err, item) => {
208
+ expect(err).toBe(customError);
209
+ expect(item).toBe(2);
210
+ return true; // Continue processing
211
+ }
212
+ }
213
+ );
214
+
215
+ expect(results).toHaveLength(3);
216
+ expect(results[0]).toEqual({ success: true, result: 10 });
217
+ expect(results[1]).toEqual({ success: false, error: customError });
218
+ expect(results[2]).toEqual({ success: true, result: 30 });
219
+ });
220
+
221
+ test("should handle multiple errors with continue", async () => {
222
+ const items = [1, 2, 3, 4, 5];
223
+ const errorItems = new Set([2, 4]);
224
+
225
+ const results = await processBatch(
226
+ items,
227
+ async (n) => {
228
+ if (errorItems.has(n)) {
229
+ throw new Error(`Error for item ${n}`);
230
+ }
231
+ return n * 2;
232
+ },
233
+ {
234
+ concurrency: 2,
235
+ onError: (err, _item) => {
236
+ return true; // Continue on all errors
237
+ }
238
+ }
239
+ );
240
+
241
+ expect(results).toHaveLength(5);
242
+ expect(results.filter(r => r.success)).toHaveLength(3);
243
+ expect(results.filter(r => !r.success)).toHaveLength(2);
244
+
245
+ // Check successful results
246
+ expect(results[0]).toEqual({ success: true, result: 2 });
247
+ expect(results[2]).toEqual({ success: true, result: 6 });
248
+ expect(results[4]).toEqual({ success: true, result: 10 });
249
+
250
+ // Check failed results
251
+ expect(results[1]?.success).toBe(false);
252
+ expect(results[1]?.error?.message).toBe("Error for item 2");
253
+ expect(results[3]?.success).toBe(false);
254
+ expect(results[3]?.error?.message).toBe("Error for item 4");
255
+ });
256
+ });
@@ -0,0 +1,165 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { withRetry, RetryError, shouldRetryGitHubError } from "../../../src/utils/retry";
3
+
4
+ describe("retry utilities", () => {
5
+ describe("withRetry", () => {
6
+ test("should succeed on first attempt", async () => {
7
+ const fn = () => Promise.resolve("success");
8
+ const result = await withRetry(fn);
9
+ expect(result).toBe("success");
10
+ });
11
+
12
+ test("should retry on failure and eventually succeed", async () => {
13
+ let attempts = 0;
14
+ const fn = () => {
15
+ attempts++;
16
+ if (attempts < 3) {
17
+ return Promise.reject(new Error("temporary error"));
18
+ }
19
+ return Promise.resolve("success");
20
+ };
21
+
22
+ const result = await withRetry(fn, { maxAttempts: 3 });
23
+ expect(result).toBe("success");
24
+ expect(attempts).toBe(3);
25
+ });
26
+
27
+ test("should throw RetryError after max attempts", async () => {
28
+ const fn = () => Promise.reject(new Error("persistent error"));
29
+
30
+ try {
31
+ await withRetry(fn, { maxAttempts: 3 });
32
+ expect(true).toBe(false); // Should not reach here
33
+ } catch (error) {
34
+ expect(error).toBeInstanceOf(RetryError);
35
+ expect((error as RetryError).attempts).toBe(3);
36
+ expect((error as RetryError).lastError.message).toBe("persistent error");
37
+ }
38
+ });
39
+
40
+ test("should use exponential backoff", async () => {
41
+ const delays: number[] = [];
42
+ let lastTime = Date.now();
43
+
44
+ const fn = () => {
45
+ const currentTime = Date.now();
46
+ delays.push(currentTime - lastTime);
47
+ lastTime = currentTime;
48
+ return Promise.reject(new Error("error"));
49
+ };
50
+
51
+ try {
52
+ await withRetry(fn, {
53
+ maxAttempts: 4,
54
+ initialDelay: 100,
55
+ backoffFactor: 2,
56
+ onRetry: () => {
57
+ // Track delays between retries
58
+ }
59
+ });
60
+ } catch (error) {
61
+ // Expected to fail
62
+ }
63
+
64
+ // First attempt should be immediate (no delay)
65
+ expect(delays[0]).toBeLessThan(50);
66
+
67
+ // Subsequent delays should increase exponentially
68
+ expect(delays[1]).toBeGreaterThan(80); // ~100ms
69
+ expect(delays[2]).toBeGreaterThan(180); // ~200ms
70
+ expect(delays[3]).toBeGreaterThan(380); // ~400ms
71
+ });
72
+
73
+ test("should respect maxDelay", async () => {
74
+ const fn = () => Promise.reject(new Error("error"));
75
+ const startTime = Date.now();
76
+
77
+ try {
78
+ await withRetry(fn, {
79
+ maxAttempts: 3,
80
+ initialDelay: 1000,
81
+ maxDelay: 1500,
82
+ backoffFactor: 3 // Would result in 3000ms delay without maxDelay
83
+ });
84
+ } catch (error) {
85
+ // Expected to fail
86
+ }
87
+
88
+ const totalTime = Date.now() - startTime;
89
+ expect(totalTime).toBeLessThan(3000); // Should be faster than 1000 + 3000ms
90
+ });
91
+
92
+ test("should not retry when shouldRetry returns false", async () => {
93
+ let attempts = 0;
94
+ const fn = () => {
95
+ attempts++;
96
+ return Promise.reject(new Error("do not retry"));
97
+ };
98
+
99
+ const shouldRetry = (error: Error) => error.message !== "do not retry";
100
+
101
+ try {
102
+ await withRetry(fn, { maxAttempts: 3, shouldRetry });
103
+ } catch (error) {
104
+ // Expected to fail
105
+ }
106
+
107
+ expect(attempts).toBe(1); // Only one attempt
108
+ });
109
+
110
+ test("should call onRetry callback", async () => {
111
+ const retryCallbacks: Array<{ error: Error; attempt: number; delay: number }> = [];
112
+
113
+ const fn = () => Promise.reject(new Error("error"));
114
+ const onRetry = (error: Error, attempt: number, nextDelay: number) => {
115
+ retryCallbacks.push({ error, attempt, delay: nextDelay });
116
+ };
117
+
118
+ try {
119
+ await withRetry(fn, { maxAttempts: 3, onRetry });
120
+ } catch (error) {
121
+ // Expected to fail
122
+ }
123
+
124
+ expect(retryCallbacks).toHaveLength(2);
125
+ expect(retryCallbacks[0]?.attempt).toBe(1);
126
+ expect(retryCallbacks[1]?.attempt).toBe(2);
127
+ expect(retryCallbacks[0]?.delay).toBe(1000);
128
+ expect(retryCallbacks[1]?.delay).toBe(2000);
129
+ });
130
+ });
131
+
132
+ describe("shouldRetryGitHubError", () => {
133
+ test("should retry rate limit errors", () => {
134
+ expect(shouldRetryGitHubError(new Error("Rate limit exceeded"), 1)).toBe(true);
135
+ expect(shouldRetryGitHubError(new Error("API rate limit exceeded"), 1)).toBe(true);
136
+ expect(shouldRetryGitHubError(new Error("429 Too Many Requests"), 1)).toBe(true);
137
+ });
138
+
139
+ test("should retry server errors", () => {
140
+ expect(shouldRetryGitHubError(new Error("Internal Server Error 500"), 1)).toBe(true);
141
+ expect(shouldRetryGitHubError(new Error("502 Bad Gateway"), 1)).toBe(true);
142
+ expect(shouldRetryGitHubError(new Error("503 Service Unavailable"), 1)).toBe(true);
143
+ expect(shouldRetryGitHubError(new Error("504 Gateway Timeout"), 1)).toBe(true);
144
+ });
145
+
146
+ test("should retry network errors", () => {
147
+ expect(shouldRetryGitHubError(new Error("Network error"), 1)).toBe(true);
148
+ expect(shouldRetryGitHubError(new Error("ECONNRESET"), 1)).toBe(true);
149
+ expect(shouldRetryGitHubError(new Error("ETIMEDOUT"), 1)).toBe(true);
150
+ expect(shouldRetryGitHubError(new Error("fetch failed"), 1)).toBe(true);
151
+ });
152
+
153
+ test("should not retry client errors", () => {
154
+ expect(shouldRetryGitHubError(new Error("401 Unauthorized"), 1)).toBe(false);
155
+ expect(shouldRetryGitHubError(new Error("403 Forbidden"), 1)).toBe(false);
156
+ expect(shouldRetryGitHubError(new Error("404 Not Found"), 1)).toBe(false);
157
+ expect(shouldRetryGitHubError(new Error("422 Unprocessable Entity"), 1)).toBe(false);
158
+ });
159
+
160
+ test("should retry other errors by default", () => {
161
+ expect(shouldRetryGitHubError(new Error("Unknown error"), 1)).toBe(true);
162
+ expect(shouldRetryGitHubError(new Error("Some random error"), 1)).toBe(true);
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Strip ANSI escape codes from a string
3
+ * Used for comparing ink component output in tests
4
+ *
5
+ * This is a fallback utility if NO_COLOR environment variable doesn't work.
6
+ * The preload.ts script sets NO_COLOR=1, but some libraries may ignore it.
7
+ */
8
+ export function stripAnsi(str: string | undefined): string {
9
+ if (!str) return "";
10
+ // Match ANSI escape sequences: ESC[ followed by params and command
11
+ const ansiPattern = /\u001b\[[0-9;]*[a-zA-Z]/g;
12
+ return str.replace(ansiPattern, "");
13
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tests for timeout utility
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import { withTimeout, createTimeoutController, TimeoutError } from "../../../src/utils/timeout.ts";
7
+
8
+ describe("withTimeout", () => {
9
+ test("should resolve when promise completes before timeout", async () => {
10
+ const promise = Promise.resolve("success");
11
+ const result = await withTimeout(promise, 1000, "test operation");
12
+ expect(result).toBe("success");
13
+ });
14
+
15
+ test("should reject with TimeoutError when promise takes too long", async () => {
16
+ const promise = new Promise((resolve) => setTimeout(() => resolve("too late"), 2000));
17
+
18
+ await expect(
19
+ withTimeout(promise, 100, "slow operation")
20
+ ).rejects.toThrow(TimeoutError);
21
+ });
22
+
23
+ test("should include operation name in timeout error", async () => {
24
+ const promise = new Promise((resolve) => setTimeout(() => resolve("too late"), 2000));
25
+
26
+ try {
27
+ await withTimeout(promise, 100, "git clone");
28
+ } catch (error) {
29
+ expect(error).toBeInstanceOf(TimeoutError);
30
+ expect((error as TimeoutError).operation).toBe("git clone");
31
+ expect((error as Error).message).toContain("git clone");
32
+ expect((error as Error).message).toContain("100ms");
33
+ }
34
+ });
35
+
36
+ test("should handle already resolved promises", async () => {
37
+ const result = await withTimeout(Promise.resolve("immediate"), 1000, "test");
38
+ expect(result).toBe("immediate");
39
+ });
40
+
41
+ test("should handle already rejected promises", async () => {
42
+ const error = new Error("test error");
43
+ await expect(
44
+ withTimeout(Promise.reject(error), 1000, "test")
45
+ ).rejects.toThrow("test error");
46
+ });
47
+ });
48
+
49
+ describe("createTimeoutController", () => {
50
+ test("should abort after timeout", async () => {
51
+ const controller = createTimeoutController(100, "test operation");
52
+
53
+ expect(controller.signal.aborted).toBe(false);
54
+
55
+ await new Promise(resolve => setTimeout(resolve, 150));
56
+
57
+ expect(controller.signal.aborted).toBe(true);
58
+ });
59
+
60
+ test("should not abort before timeout", async () => {
61
+ const controller = createTimeoutController(1000, "test operation");
62
+
63
+ expect(controller.signal.aborted).toBe(false);
64
+
65
+ await new Promise(resolve => setTimeout(resolve, 100));
66
+
67
+ expect(controller.signal.aborted).toBe(false);
68
+ });
69
+
70
+ test("should include timeout error in abort reason", async () => {
71
+ const controller = createTimeoutController(100, "test operation");
72
+
73
+ await new Promise(resolve => setTimeout(resolve, 150));
74
+
75
+ expect(controller.signal.reason).toBeInstanceOf(TimeoutError);
76
+ expect((controller.signal.reason as TimeoutError).operation).toBe("test operation");
77
+ });
78
+
79
+ test("should clean up timer when manually aborted", async () => {
80
+ const controller = createTimeoutController(1000, "test operation");
81
+
82
+ // Manually abort
83
+ controller.abort();
84
+
85
+ expect(controller.signal.aborted).toBe(true);
86
+
87
+ // Wait past the original timeout
88
+ await new Promise(resolve => setTimeout(resolve, 1100));
89
+
90
+ // Should still be aborted (no double abort)
91
+ expect(controller.signal.aborted).toBe(true);
92
+ });
93
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }