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,676 @@
1
+ /**
2
+ * GitHubService interface and API implementation
3
+ * Abstracts GitHub API operations for testability
4
+ */
5
+
6
+ import type {
7
+ GitHubUser,
8
+ GitHubOrg,
9
+ GitHubRepoData,
10
+ GetReposOptions,
11
+ CreateRepoOptions,
12
+ CloneOptions,
13
+ } from "./types.ts";
14
+
15
+ // Re-export types for consumers
16
+ export type { GitHubRepoData } from "./types.ts";
17
+ import type { OperationResult } from "../types/index.ts";
18
+ import { errorToString } from "../utils/errors.ts";
19
+ import { withRetry, shouldRetryGitHubError } from "../utils/retry.ts";
20
+ import { GITHUB_API, GIT } from "../constants.ts";
21
+
22
+ // ============================================================================
23
+ // GitHubService Interface
24
+ // ============================================================================
25
+
26
+ export interface GitHubService {
27
+ /**
28
+ * Check if a GitHub token is available
29
+ * @returns true if GITHUB_TOKEN or GH_TOKEN environment variable is set
30
+ */
31
+ hasToken(): boolean;
32
+
33
+ /**
34
+ * Get the GitHub token from environment
35
+ * @returns The token string or null if not set
36
+ */
37
+ getToken(): string | null;
38
+
39
+ /**
40
+ * Get the authenticated user's information
41
+ * @returns Promise resolving to user data
42
+ * @throws {GitHubAPIError} If authentication fails
43
+ */
44
+ getAuthenticatedUser(): Promise<GitHubUser>;
45
+
46
+ /**
47
+ * Get organizations the authenticated user belongs to
48
+ * @returns Promise resolving to array of organization data
49
+ */
50
+ getUserOrgs(): Promise<GitHubOrg[]>;
51
+
52
+ /**
53
+ * Get repositories owned by the authenticated user
54
+ * @param options - Filter options for repositories
55
+ * @returns Promise resolving to array of repository data
56
+ */
57
+ getUserRepos(options?: GetReposOptions): Promise<GitHubRepoData[]>;
58
+
59
+ /**
60
+ * Get repositories for a specific organization
61
+ * @param org - Organization name
62
+ * @param options - Filter options for repositories
63
+ * @returns Promise resolving to array of repository data
64
+ */
65
+ getOrgRepos(org: string, options?: GetReposOptions): Promise<GitHubRepoData[]>;
66
+
67
+ /**
68
+ * Get all repositories accessible to the authenticated user
69
+ * @param options - Filter options for repositories
70
+ * @returns Promise resolving to array of repository data
71
+ */
72
+ getAllRepos(options?: GetReposOptions): Promise<GitHubRepoData[]>;
73
+
74
+ /**
75
+ * Get a specific repository by owner and name
76
+ * @param owner - Repository owner (user or organization)
77
+ * @param name - Repository name
78
+ * @returns Promise resolving to repository data
79
+ */
80
+ getRepo(owner: string, name: string): Promise<GitHubRepoData>;
81
+
82
+ /**
83
+ * Search for repositories using GitHub's search API
84
+ * @param query - Search query
85
+ * @param options - Search options (sort, order, perPage)
86
+ * @returns Promise resolving to array of repository data
87
+ */
88
+ searchRepos(query: string, options?: { sort?: string; order?: string; perPage?: number }): Promise<GitHubRepoData[]>;
89
+
90
+ /**
91
+ * Create a new GitHub repository
92
+ * @param options - Repository creation options
93
+ * @returns Promise with success status and optional URL or error
94
+ */
95
+ createRepo(options: CreateRepoOptions): Promise<{ success: boolean; url?: string; error?: string }>;
96
+
97
+ /**
98
+ * Archive a GitHub repository
99
+ * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user
100
+ * @returns Promise with success status and optional error
101
+ */
102
+ archiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>;
103
+
104
+ /**
105
+ * Unarchive a GitHub repository
106
+ * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user
107
+ * @returns Promise with success status and optional error
108
+ */
109
+ unarchiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>;
110
+
111
+ /**
112
+ * Delete a GitHub repository
113
+ * @param ownerRepo - Repository in "owner/repo" format or just "repo" for authenticated user
114
+ * @returns Promise with success status and optional error
115
+ */
116
+ deleteRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }>;
117
+
118
+ /**
119
+ * Clone a GitHub repository
120
+ * @param repo - Repository data
121
+ * @param options - Clone options (useSSH, targetDir)
122
+ * @returns Promise resolving to operation result
123
+ */
124
+ cloneRepo(repo: GitHubRepoData, options: CloneOptions): Promise<OperationResult>;
125
+ }
126
+
127
+ // ============================================================================
128
+ // GitHub API Response Types (internal)
129
+ // ============================================================================
130
+
131
+ interface GitHubApiRepo {
132
+ id: number;
133
+ name: string;
134
+ full_name: string;
135
+ description: string | null;
136
+ html_url: string;
137
+ ssh_url: string;
138
+ clone_url: string;
139
+ private: boolean;
140
+ archived: boolean;
141
+ fork: boolean;
142
+ created_at: string;
143
+ updated_at: string;
144
+ pushed_at: string | null;
145
+ size: number;
146
+ stargazers_count: number;
147
+ forks_count: number;
148
+ open_issues_count: number;
149
+ watchers_count: number;
150
+ topics: string[];
151
+ license: {
152
+ name: string;
153
+ } | null;
154
+ has_issues: boolean;
155
+ has_wiki: boolean;
156
+ has_discussions: boolean;
157
+ language: string | null;
158
+ default_branch: string;
159
+ owner: {
160
+ login: string;
161
+ type: "User" | "Organization";
162
+ };
163
+ }
164
+
165
+ // ============================================================================
166
+ // Helper: Convert API response to our type
167
+ // ============================================================================
168
+
169
+ function toGitHubRepoData(repo: GitHubApiRepo): GitHubRepoData {
170
+ return {
171
+ id: repo.id,
172
+ name: repo.name,
173
+ fullName: repo.full_name,
174
+ description: repo.description,
175
+ htmlUrl: repo.html_url,
176
+ sshUrl: repo.ssh_url,
177
+ cloneUrl: repo.clone_url,
178
+ isPrivate: repo.private,
179
+ isArchived: repo.archived,
180
+ isFork: repo.fork,
181
+ createdAt: repo.created_at,
182
+ updatedAt: repo.updated_at,
183
+ pushedAt: repo.pushed_at,
184
+ size: repo.size,
185
+ stargazersCount: repo.stargazers_count,
186
+ forksCount: repo.forks_count,
187
+ openIssuesCount: repo.open_issues_count,
188
+ watchersCount: repo.watchers_count,
189
+ topics: repo.topics || [],
190
+ license: repo.license,
191
+ hasIssues: repo.has_issues,
192
+ hasWiki: repo.has_wiki,
193
+ hasDiscussions: repo.has_discussions,
194
+ language: repo.language,
195
+ defaultBranch: repo.default_branch,
196
+ owner: repo.owner,
197
+ };
198
+ }
199
+
200
+ // ============================================================================
201
+ // GitHub API Error
202
+ // ============================================================================
203
+
204
+ class GitHubAPIError extends Error {
205
+ constructor(
206
+ message: string,
207
+ public status: number,
208
+ public response?: unknown
209
+ ) {
210
+ super(message);
211
+ this.name = "GitHubAPIError";
212
+ }
213
+ }
214
+
215
+ // ============================================================================
216
+ // Helper: Validate GitHub token format
217
+ // ============================================================================
218
+
219
+ /**
220
+ * Validate GitHub token format
221
+ * GitHub tokens can be:
222
+ * - Classic tokens: 40 character hex string
223
+ * - Fine-grained PATs: ghp_xxxx (40 chars after prefix)
224
+ * - GitHub App tokens: ghs_xxxx
225
+ * - OAuth tokens: gho_xxxx
226
+ */
227
+ function validateTokenFormat(token: string): boolean {
228
+ const patterns = [
229
+ /^ghp_[a-zA-Z0-9]{36}$/, // Personal access tokens (new)
230
+ /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/, // Fine-grained PATs
231
+ /^gho_[a-zA-Z0-9]{35,40}$/, // OAuth tokens (flexible length)
232
+ /^ghs_[a-zA-Z0-9]{36}$/, // GitHub App tokens
233
+ /^ghr_[a-zA-Z0-9]{36}$/, // Refresh tokens
234
+ /^[a-f0-9]{40}$/, // Classic tokens (40 hex chars)
235
+ ];
236
+
237
+ return patterns.some(pattern => pattern.test(token));
238
+ }
239
+
240
+ // ============================================================================
241
+ // API Implementation
242
+ // ============================================================================
243
+
244
+ export const apiGitHubService: GitHubService = {
245
+ hasToken(): boolean {
246
+ // Treat empty strings as missing
247
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
248
+ return token !== null;
249
+ },
250
+
251
+ getToken(): string | null {
252
+ // Prefer GITHUB_TOKEN, fall back to GH_TOKEN, ignore empty strings
253
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
254
+
255
+ if (token && !validateTokenFormat(token)) {
256
+ console.warn('Warning: GITHUB_TOKEN format appears invalid. Expected ghp_*, github_pat_*, or 40-char hex string.');
257
+ }
258
+
259
+ return token;
260
+ },
261
+
262
+ async getAuthenticatedUser(): Promise<GitHubUser> {
263
+ return await githubFetch<GitHubUser>("/user", this.getToken()!);
264
+ },
265
+
266
+ async getUserOrgs(): Promise<GitHubOrg[]> {
267
+ return await githubFetch<GitHubOrg[]>("/user/orgs", this.getToken()!);
268
+ },
269
+
270
+ async getUserRepos(options?: GetReposOptions): Promise<GitHubRepoData[]> {
271
+ const {
272
+ type = "owner",
273
+ sort = "pushed",
274
+ direction = "desc",
275
+ includeArchived = false,
276
+ includeForks = true,
277
+ } = options ?? {};
278
+
279
+ const allRepos: GitHubRepoData[] = [];
280
+ let page = 1;
281
+ const perPage = GITHUB_API.PAGE_SIZE;
282
+
283
+ while (true) {
284
+ const repos = await githubFetch<GitHubApiRepo[]>(
285
+ `/user/repos?type=${type}&sort=${sort}&direction=${direction}&per_page=${perPage}&page=${page}`,
286
+ this.getToken()!
287
+ );
288
+
289
+ if (repos.length === 0) break;
290
+
291
+ const filtered = repos
292
+ .filter((repo) => {
293
+ if (!includeArchived && repo.archived) return false;
294
+ if (!includeForks && repo.fork) return false;
295
+ return true;
296
+ })
297
+ .map(toGitHubRepoData);
298
+
299
+ allRepos.push(...filtered);
300
+ page++;
301
+
302
+ if (repos.length < perPage) break;
303
+ }
304
+
305
+ return allRepos;
306
+ },
307
+
308
+ async getOrgRepos(org: string, options?: GetReposOptions): Promise<GitHubRepoData[]> {
309
+ const {
310
+ type = "all",
311
+ sort = "pushed",
312
+ direction = "desc",
313
+ includeArchived = false,
314
+ } = options ?? {};
315
+
316
+ const allRepos: GitHubRepoData[] = [];
317
+ let page = 1;
318
+ const perPage = GITHUB_API.PAGE_SIZE;
319
+
320
+ while (true) {
321
+ const repos = await githubFetch<GitHubApiRepo[]>(
322
+ `/orgs/${org}/repos?type=${type}&sort=${sort}&direction=${direction}&per_page=${perPage}&page=${page}`,
323
+ this.getToken()!
324
+ );
325
+
326
+ if (repos.length === 0) break;
327
+
328
+ const filtered = repos
329
+ .filter((repo) => !includeArchived || !repo.archived)
330
+ .map(toGitHubRepoData);
331
+
332
+ allRepos.push(...filtered);
333
+ page++;
334
+
335
+ if (repos.length < perPage) break;
336
+ }
337
+
338
+ return allRepos;
339
+ },
340
+
341
+ async getAllRepos(options?: GetReposOptions): Promise<GitHubRepoData[]> {
342
+ const {
343
+ includeOrgs = true,
344
+ includeArchived = false,
345
+ includeForks = true,
346
+ } = options ?? {};
347
+
348
+ // Get user's own repos
349
+ const userRepos = await this.getUserRepos({
350
+ type: "owner",
351
+ includeArchived,
352
+ includeForks,
353
+ });
354
+
355
+ if (!includeOrgs) {
356
+ return userRepos;
357
+ }
358
+
359
+ // Get orgs and their repos
360
+ const orgs = await this.getUserOrgs();
361
+ const orgRepoPromises = orgs.map((org) =>
362
+ this.getOrgRepos(org.login, { includeArchived })
363
+ );
364
+ const orgReposArrays = await Promise.all(orgRepoPromises);
365
+ const orgRepos = orgReposArrays.flat();
366
+
367
+ // Combine and dedupe by fullName
368
+ const repoMap = new Map<string, GitHubRepoData>();
369
+ for (const repo of [...userRepos, ...orgRepos]) {
370
+ repoMap.set(repo.fullName, repo);
371
+ }
372
+
373
+ return Array.from(repoMap.values());
374
+ },
375
+
376
+ async getRepo(owner: string, name: string): Promise<GitHubRepoData> {
377
+ const repo = await githubFetch<GitHubApiRepo>(`/repos/${owner}/${name}`, this.getToken()!);
378
+ return toGitHubRepoData(repo);
379
+ },
380
+
381
+ async searchRepos(
382
+ query: string,
383
+ options?: { sort?: string; order?: string; perPage?: number }
384
+ ): Promise<GitHubRepoData[]> {
385
+ const { sort, order = "desc", perPage = 30 } = options ?? {};
386
+ const sortParam = sort ? `&sort=${sort}` : "";
387
+
388
+ const result = await githubFetch<{ items: GitHubApiRepo[] }>(
389
+ `/search/repositories?q=${encodeURIComponent(query)}${sortParam}&order=${order}&per_page=${perPage}`,
390
+ this.getToken()!
391
+ );
392
+
393
+ return result.items.map(toGitHubRepoData);
394
+ },
395
+
396
+ async createRepo(
397
+ options: CreateRepoOptions
398
+ ): Promise<{ success: boolean; url?: string; error?: string }> {
399
+ const { name, description, isPrivate = true, localPath } = options;
400
+ const token = this.getToken();
401
+
402
+ if (!token) {
403
+ return { success: false, error: "GITHUB_TOKEN not set" };
404
+ }
405
+
406
+ try {
407
+ // Create repo via GitHub API
408
+ const response = await fetch(`${GITHUB_API.BASE_URL}/user/repos`, {
409
+ method: "POST",
410
+ headers: {
411
+ Accept: "application/vnd.github+json",
412
+ Authorization: `Bearer ${token}`,
413
+ "X-GitHub-Api-Version": GITHUB_API.API_VERSION,
414
+ "Content-Type": "application/json",
415
+ },
416
+ body: JSON.stringify({
417
+ name,
418
+ description: description ?? undefined,
419
+ private: isPrivate,
420
+ auto_init: false,
421
+ }),
422
+ });
423
+
424
+ if (!response.ok) {
425
+ const error = await response.json().catch(() => ({})) as { message?: string };
426
+ return {
427
+ success: false,
428
+ error: error.message ?? `API error: ${response.statusText}`,
429
+ };
430
+ }
431
+
432
+ const repoData = await response.json() as { html_url: string; ssh_url: string; clone_url: string };
433
+
434
+ // If localPath provided, set up local repo connection
435
+ if (localPath) {
436
+ const sshUrl = repoData.ssh_url;
437
+
438
+ // Add remote
439
+ const addResult = await Bun.$`git -C ${localPath} remote add origin ${sshUrl}`.quiet().nothrow();
440
+ if (addResult.exitCode !== 0) {
441
+ // Remote might already exist, try to set url instead
442
+ await Bun.$`git -C ${localPath} remote set-url origin ${sshUrl}`.quiet().nothrow();
443
+ }
444
+
445
+ // Get current branch and push
446
+ const branchResult = await Bun.$`git -C ${localPath} rev-parse --abbrev-ref HEAD`.quiet().text();
447
+ const branch = branchResult.trim() || GIT.DEFAULT_BRANCH;
448
+
449
+ const pushResult = await Bun.$`git -C ${localPath} push -u origin ${branch}`.quiet().nothrow();
450
+ if (pushResult.exitCode !== 0) {
451
+ return {
452
+ success: true,
453
+ url: repoData.html_url,
454
+ error: "Repo created but push failed - push manually",
455
+ };
456
+ }
457
+ }
458
+
459
+ return { success: true, url: repoData.html_url };
460
+ } catch (error) {
461
+ return {
462
+ success: false,
463
+ error: errorToString(error),
464
+ };
465
+ }
466
+ },
467
+
468
+ async archiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> {
469
+ const token = this.getToken();
470
+
471
+ if (!token) {
472
+ return { success: false, error: "GITHUB_TOKEN not set" };
473
+ }
474
+
475
+ try {
476
+ // Resolve owner/repo if just repo name provided
477
+ let fullName = ownerRepo;
478
+ if (!ownerRepo.includes("/")) {
479
+ const user = await this.getAuthenticatedUser();
480
+ fullName = `${user.login}/${ownerRepo}`;
481
+ }
482
+
483
+ const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, {
484
+ method: "PATCH",
485
+ headers: {
486
+ Accept: "application/vnd.github+json",
487
+ Authorization: `Bearer ${token}`,
488
+ "X-GitHub-Api-Version": GITHUB_API.API_VERSION,
489
+ "Content-Type": "application/json",
490
+ },
491
+ body: JSON.stringify({ archived: true }),
492
+ });
493
+
494
+ if (!response.ok) {
495
+ const error = await response.json().catch(() => ({})) as { message?: string };
496
+ return {
497
+ success: false,
498
+ error: error.message ?? `API error: ${response.statusText}`,
499
+ };
500
+ }
501
+
502
+ return { success: true };
503
+ } catch (error) {
504
+ return {
505
+ success: false,
506
+ error: errorToString(error),
507
+ };
508
+ }
509
+ },
510
+
511
+ async unarchiveRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> {
512
+ const token = this.getToken();
513
+
514
+ if (!token) {
515
+ return { success: false, error: "GITHUB_TOKEN not set" };
516
+ }
517
+
518
+ try {
519
+ // Resolve owner/repo if just repo name provided
520
+ let fullName = ownerRepo;
521
+ if (!ownerRepo.includes("/")) {
522
+ const user = await this.getAuthenticatedUser();
523
+ fullName = `${user.login}/${ownerRepo}`;
524
+ }
525
+
526
+ const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, {
527
+ method: "PATCH",
528
+ headers: {
529
+ Accept: "application/vnd.github+json",
530
+ Authorization: `Bearer ${token}`,
531
+ "X-GitHub-Api-Version": GITHUB_API.API_VERSION,
532
+ "Content-Type": "application/json",
533
+ },
534
+ body: JSON.stringify({ archived: false }),
535
+ });
536
+
537
+ if (!response.ok) {
538
+ const error = await response.json().catch(() => ({})) as { message?: string };
539
+ return {
540
+ success: false,
541
+ error: error.message ?? `API error: ${response.statusText}`,
542
+ };
543
+ }
544
+
545
+ return { success: true };
546
+ } catch (error) {
547
+ return {
548
+ success: false,
549
+ error: errorToString(error),
550
+ };
551
+ }
552
+ },
553
+
554
+ async deleteRepo(ownerRepo: string): Promise<{ success: boolean; error?: string }> {
555
+ const token = this.getToken();
556
+
557
+ if (!token) {
558
+ return { success: false, error: "GITHUB_TOKEN not set" };
559
+ }
560
+
561
+ try {
562
+ // Resolve owner/repo if just repo name provided
563
+ let fullName = ownerRepo;
564
+ if (!ownerRepo.includes("/")) {
565
+ const user = await this.getAuthenticatedUser();
566
+ fullName = `${user.login}/${ownerRepo}`;
567
+ }
568
+
569
+ const response = await fetch(`${GITHUB_API.BASE_URL}/repos/${fullName}`, {
570
+ method: "DELETE",
571
+ headers: {
572
+ Accept: "application/vnd.github+json",
573
+ Authorization: `Bearer ${token}`,
574
+ "X-GitHub-Api-Version": GITHUB_API.API_VERSION,
575
+ },
576
+ });
577
+
578
+ if (!response.ok) {
579
+ const error = await response.json().catch(() => ({})) as { message?: string };
580
+ return {
581
+ success: false,
582
+ error: error.message ?? `API error: ${response.statusText}`,
583
+ };
584
+ }
585
+
586
+ return { success: true };
587
+ } catch (error) {
588
+ return {
589
+ success: false,
590
+ error: errorToString(error),
591
+ };
592
+ }
593
+ },
594
+
595
+ async cloneRepo(repo: GitHubRepoData, options: CloneOptions): Promise<OperationResult> {
596
+ const { useSSH = true, targetDir } = options;
597
+ const url = useSSH ? repo.sshUrl : repo.cloneUrl;
598
+ const start = Date.now();
599
+
600
+ try {
601
+ const result = await Bun.$`git clone ${url} ${targetDir}`.quiet();
602
+ if (result.exitCode === 0) {
603
+ return {
604
+ success: true,
605
+ projectPath: targetDir,
606
+ operation: "clone",
607
+ message: `Cloned ${repo.fullName}`,
608
+ duration: Date.now() - start,
609
+ };
610
+ }
611
+ return {
612
+ success: false,
613
+ projectPath: targetDir,
614
+ operation: "clone",
615
+ error: "Clone failed",
616
+ duration: Date.now() - start,
617
+ };
618
+ } catch (error) {
619
+ return {
620
+ success: false,
621
+ projectPath: targetDir,
622
+ operation: "clone",
623
+ error: errorToString(error),
624
+ duration: Date.now() - start,
625
+ };
626
+ }
627
+ },
628
+ };
629
+
630
+ // ============================================================================
631
+ // Helper: Make authenticated GitHub API request
632
+ // ============================================================================
633
+
634
+ async function githubFetch<T>(endpoint: string, token: string): Promise<T> {
635
+ return withRetry(
636
+ async () => {
637
+ if (!token) {
638
+ throw new GitHubAPIError("GITHUB_TOKEN not set", 401);
639
+ }
640
+
641
+ const url = endpoint.startsWith("https://")
642
+ ? endpoint
643
+ : `${GITHUB_API.BASE_URL}${endpoint}`;
644
+
645
+ const response = await fetch(url, {
646
+ headers: {
647
+ Accept: "application/vnd.github+json",
648
+ Authorization: `Bearer ${token}`,
649
+ "X-GitHub-Api-Version": GITHUB_API.API_VERSION,
650
+ },
651
+ });
652
+
653
+ if (!response.ok) {
654
+ const error = await response.json().catch(() => ({}));
655
+ throw new GitHubAPIError(
656
+ `GitHub API error: ${response.statusText}`,
657
+ response.status,
658
+ error
659
+ );
660
+ }
661
+
662
+ return response.json() as Promise<T>;
663
+ },
664
+ {
665
+ maxAttempts: GITHUB_API.MAX_RETRIES,
666
+ initialDelay: GITHUB_API.INITIAL_RETRY_DELAY,
667
+ shouldRetry: shouldRetryGitHubError,
668
+ }
669
+ );
670
+ }
671
+
672
+ // ============================================================================
673
+ // Default Export
674
+ // ============================================================================
675
+
676
+ export const defaultGitHubService = apiGitHubService;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Service abstractions for testability
3
+ *
4
+ * These interfaces abstract external dependencies (git CLI, GitHub API)
5
+ * so they can be easily mocked in tests.
6
+ */
7
+
8
+ // Types
9
+ export type {
10
+ GitCommandResult,
11
+ GitStatusResult,
12
+ SubmoduleEntry,
13
+ GitHubUser,
14
+ GitHubOrg,
15
+ GitHubRepoData,
16
+ GetReposOptions,
17
+ CreateRepoOptions,
18
+ CloneOptions,
19
+ ProgressCallback,
20
+ } from "./types.ts";
21
+
22
+ // Git Service
23
+ export type { GitService } from "./git.ts";
24
+ export { bunGitService, defaultGitService } from "./git.ts";
25
+
26
+ // GitHub Service
27
+ export type { GitHubService } from "./github.ts";
28
+ export { apiGitHubService, defaultGitHubService } from "./github.ts";