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,311 @@
1
+ /**
2
+ * GitHub Authentication via gh CLI
3
+ *
4
+ * Uses the GitHub CLI (gh) for authentication, which handles:
5
+ * - Device flow OAuth
6
+ * - Token storage and refresh
7
+ * - SSO and enterprise GitHub
8
+ * - Secure credential storage
9
+ */
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export interface AuthResult {
16
+ success: boolean;
17
+ token?: string;
18
+ error?: string;
19
+ user?: string;
20
+ }
21
+
22
+ // ============================================================================
23
+ // Token Management
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Get token from environment or gh CLI
28
+ */
29
+ export function getToken(): string | null {
30
+ // First check environment variables (highest priority)
31
+ // Treat empty strings as missing
32
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
33
+ if (envToken) {
34
+ return envToken;
35
+ }
36
+
37
+ // Try to get token from gh CLI (synchronous check not possible, return null)
38
+ // Use getTokenAsync for full check
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Set token in environment (for programmatic use)
44
+ * This does NOT persist the token - it only sets it for the current session
45
+ */
46
+ export function setToken(token: string): void {
47
+ process.env.GITHUB_TOKEN = token;
48
+ }
49
+
50
+ /**
51
+ * Get token from environment or gh CLI (async version)
52
+ */
53
+ export async function getTokenAsync(): Promise<string | null> {
54
+ // First check environment variables
55
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
56
+ if (envToken) {
57
+ return envToken;
58
+ }
59
+
60
+ // Try to get token from gh CLI
61
+ if (process.env.GITTY_IGNORE_GH) return null;
62
+
63
+ try {
64
+ const result = await Bun.$`gh auth token`.quiet().nothrow();
65
+ if (result.exitCode === 0) {
66
+ const token = result.stdout.toString().trim();
67
+ if (token) {
68
+ // Cache in environment for this session
69
+ process.env.GITHUB_TOKEN = token;
70
+ return token;
71
+ }
72
+ }
73
+ } catch {
74
+ // gh not installed or not logged in
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Check if gh CLI is installed
82
+ */
83
+ export async function isGhInstalled(): Promise<boolean> {
84
+ if (process.env.GITTY_IGNORE_GH) return false;
85
+
86
+ try {
87
+ const result = await Bun.$`gh --version`.quiet().nothrow();
88
+ return result.exitCode === 0;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Check if authenticated (has valid token)
96
+ */
97
+ export function isAuthenticated(): boolean {
98
+ return getToken() !== null;
99
+ }
100
+
101
+ /**
102
+ * Check if authenticated (async - includes gh CLI check)
103
+ */
104
+ export async function isAuthenticatedAsync(): Promise<boolean> {
105
+ const token = await getTokenAsync();
106
+ return token !== null;
107
+ }
108
+
109
+ // ============================================================================
110
+ // Login / Logout
111
+ // ============================================================================
112
+
113
+ /**
114
+ * Login to GitHub using gh CLI device flow
115
+ */
116
+ export async function login(): Promise<AuthResult> {
117
+ // Check if gh is installed
118
+ const ghInstalled = await isGhInstalled();
119
+ if (!ghInstalled) {
120
+ return {
121
+ success: false,
122
+ error: "GitHub CLI (gh) is not installed. Install it from: https://cli.github.com",
123
+ };
124
+ }
125
+
126
+ try {
127
+ // Run gh auth login with web flow
128
+ // This will open browser and handle the OAuth flow
129
+ const result = await Bun.$`gh auth login --web -h github.com`.nothrow();
130
+
131
+ if (result.exitCode !== 0) {
132
+ const stderr = result.stderr.toString();
133
+ return {
134
+ success: false,
135
+ error: stderr || "Login failed",
136
+ };
137
+ }
138
+
139
+ // Get the token and user info
140
+ const tokenResult = await Bun.$`gh auth token`.quiet();
141
+ const token = tokenResult.stdout.toString().trim();
142
+
143
+ const userResult = await Bun.$`gh api user --jq .login`.quiet().nothrow();
144
+ const user = userResult.exitCode === 0 ? userResult.stdout.toString().trim() : undefined;
145
+
146
+ // Cache token in environment
147
+ process.env.GITHUB_TOKEN = token;
148
+
149
+ return {
150
+ success: true,
151
+ token,
152
+ user,
153
+ };
154
+ } catch (error) {
155
+ return {
156
+ success: false,
157
+ error: error instanceof Error ? error.message : "Login failed",
158
+ };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Logout from GitHub
164
+ */
165
+ export async function logout(): Promise<AuthResult> {
166
+ // Check if gh is installed
167
+ const ghInstalled = await isGhInstalled();
168
+ if (!ghInstalled) {
169
+ // Just clear env
170
+ delete process.env.GITHUB_TOKEN;
171
+ delete process.env.GH_TOKEN;
172
+ return { success: true };
173
+ }
174
+
175
+ try {
176
+ await Bun.$`gh auth logout -h github.com`.quiet().nothrow();
177
+ delete process.env.GITHUB_TOKEN;
178
+ delete process.env.GH_TOKEN;
179
+ return { success: true };
180
+ } catch {
181
+ // Clear env anyway
182
+ delete process.env.GITHUB_TOKEN;
183
+ delete process.env.GH_TOKEN;
184
+ return { success: true };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Verify token by calling GitHub API directly
190
+ */
191
+ async function verifyTokenWithApi(token: string): Promise<{ valid: boolean; user?: string }> {
192
+ try {
193
+ const response = await fetch("https://api.github.com/user", {
194
+ headers: {
195
+ "Accept": "application/vnd.github+json",
196
+ "Authorization": `Bearer ${token}`,
197
+ "X-GitHub-Api-Version": "2022-11-28",
198
+ },
199
+ });
200
+
201
+ if (response.ok) {
202
+ const data = await response.json() as { login: string };
203
+ return { valid: true, user: data.login };
204
+ }
205
+ return { valid: false };
206
+ } catch {
207
+ return { valid: false };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Get current auth status
213
+ */
214
+ export async function getAuthStatus(): Promise<{
215
+ authenticated: boolean;
216
+ user?: string;
217
+ source?: "env" | "gh";
218
+ }> {
219
+ // Check environment first
220
+ const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
221
+ if (envToken) {
222
+ // Verify token works using GitHub API directly
223
+ const result = await verifyTokenWithApi(envToken);
224
+ if (result.valid) {
225
+ return {
226
+ authenticated: true,
227
+ user: result.user,
228
+ source: "env",
229
+ };
230
+ }
231
+ }
232
+
233
+ // Check gh CLI
234
+ if (process.env.GITTY_IGNORE_GH) return { authenticated: false };
235
+
236
+ try {
237
+ const statusResult = await Bun.$`gh auth status -h github.com`.quiet().nothrow();
238
+ if (statusResult.exitCode === 0) {
239
+ // Get token from gh
240
+ const tokenResult = await Bun.$`gh auth token`.quiet().nothrow();
241
+ if (tokenResult.exitCode === 0) {
242
+ const token = tokenResult.stdout.toString().trim();
243
+ const result = await verifyTokenWithApi(token);
244
+ if (result.valid) {
245
+ // Cache token in environment
246
+ process.env.GITHUB_TOKEN = token;
247
+ return {
248
+ authenticated: true,
249
+ user: result.user,
250
+ source: "gh",
251
+ };
252
+ }
253
+ }
254
+ }
255
+ } catch {
256
+ // Not authenticated via gh
257
+ }
258
+
259
+ return { authenticated: false };
260
+ }
261
+
262
+ // ============================================================================
263
+ // Ensure Authenticated
264
+ // ============================================================================
265
+
266
+ /**
267
+ * Ensure authenticated - prompt for login if needed
268
+ * Returns the token if authenticated, null otherwise
269
+ */
270
+ export async function ensureAuthenticated(silent = false): Promise<string | null> {
271
+ // Try to get existing token
272
+ const token = await getTokenAsync();
273
+ if (token) {
274
+ return token;
275
+ }
276
+
277
+ if (silent) {
278
+ return null;
279
+ }
280
+
281
+ // Check if gh is installed
282
+ const ghInstalled = await isGhInstalled();
283
+ if (!ghInstalled) {
284
+ console.log("\nGitHub CLI (gh) is not installed.");
285
+ console.log("Install it from: https://cli.github.com");
286
+ console.log("\nOr set GITHUB_TOKEN environment variable manually.");
287
+ return null;
288
+ }
289
+
290
+ // Check if we are in an interactive TTY
291
+ if (!process.stdin.isTTY) {
292
+ console.log("\nNo GitHub token found. Non-interactive mode: skipping auto-login.");
293
+ return null;
294
+ }
295
+
296
+ // Prompt for login
297
+ console.log("\nNo GitHub token found. Starting authentication...\n");
298
+
299
+ const result = await login();
300
+
301
+ if (result.success && result.token) {
302
+ console.log(`\nLogged in as ${result.user ?? "unknown"}\n`);
303
+ return result.token;
304
+ }
305
+
306
+ if (result.error) {
307
+ console.error(`\nAuthentication failed: ${result.error}\n`);
308
+ }
309
+
310
+ return null;
311
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * GitHub caching utilities for unified repos
3
+ */
4
+
5
+ import type { GitHubRepoInfo } from "../types/index.ts";
6
+ import { initDb, schema, type DbInstance } from "../db/index.ts";
7
+ import { defaultGitHubService, type GitHubService } from "../services/github.ts";
8
+ import { toGitHubRepoInfo as defaultToGitHubRepoInfo } from "./unified.ts";
9
+ import { errorToString } from "../utils/errors.ts";
10
+ import { getTokenAsync } from "./auth.ts";
11
+
12
+ /**
13
+ * Dependencies that can be injected for testing
14
+ */
15
+ export interface CacheDeps {
16
+ db: DbInstance;
17
+ githubService: GitHubService;
18
+ toGitHubRepoInfo: (data: any) => GitHubRepoInfo;
19
+ /**
20
+ * Optional token provider used when no env token is set.
21
+ * Defaults to getTokenAsync which will call `gh auth token`.
22
+ */
23
+ tokenProvider: () => Promise<string | null>;
24
+ }
25
+
26
+ /**
27
+ * Check if GitHub repos cache is fresh based on TTL
28
+ */
29
+ function isGitHubCacheFresh(lastFetched: Date | null, ttlSeconds: number): boolean {
30
+ if (!lastFetched) return false;
31
+ const now = Date.now();
32
+ const ttlMs = ttlSeconds * 1000;
33
+ return now - lastFetched.getTime() < ttlMs;
34
+ }
35
+
36
+ /**
37
+ * Save GitHub repos to cache
38
+ */
39
+ export async function saveGitHubReposToCache(
40
+ repos: GitHubRepoInfo[],
41
+ deps?: { db?: DbInstance }
42
+ ): Promise<void> {
43
+ try {
44
+ const db = deps?.db ?? await initDb();
45
+ const now = new Date();
46
+
47
+ // Clear existing cache
48
+ await db.delete(schema.githubRepos).run();
49
+
50
+ // Insert all repos
51
+ for (const repo of repos) {
52
+ await db.insert(schema.githubRepos).values({
53
+ name: repo.name,
54
+ fullName: repo.fullName,
55
+ owner: repo.owner,
56
+ description: repo.description,
57
+ htmlUrl: repo.htmlUrl,
58
+ sshUrl: repo.sshUrl,
59
+ cloneUrl: repo.cloneUrl,
60
+ isPrivate: repo.isPrivate,
61
+ isArchived: repo.isArchived,
62
+ isFork: repo.isFork,
63
+ pushedAt: repo.pushedAt,
64
+ updatedAt: repo.updatedAt,
65
+ defaultBranch: repo.defaultBranch,
66
+ language: repo.language,
67
+ size: repo.size,
68
+ stargazersCount: repo.stargazersCount,
69
+ forksCount: repo.forksCount,
70
+ openIssuesCount: repo.openIssuesCount,
71
+ watchersCount: repo.watchersCount,
72
+ topics: JSON.stringify(repo.topics),
73
+ license: repo.license,
74
+ hasIssues: repo.hasIssues,
75
+ hasWiki: repo.hasWiki,
76
+ hasDiscussions: repo.hasDiscussions,
77
+ lastFetched: now,
78
+ });
79
+ }
80
+ } catch (error) {
81
+ console.error("Failed to save GitHub repos to cache:", error);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Load GitHub repos from cache
87
+ */
88
+ export async function loadGitHubReposFromCache(
89
+ deps?: { db?: DbInstance }
90
+ ): Promise<GitHubRepoInfo[]> {
91
+ try {
92
+ const db = deps?.db ?? await initDb();
93
+ const rows = await db.select().from(schema.githubRepos).all();
94
+
95
+ return rows.map(row => {
96
+ // Get the actual values from the row
97
+ const htmlUrl = row.htmlUrl;
98
+ const sshUrl = row.sshUrl;
99
+ const cloneUrl = row.cloneUrl;
100
+ const isPrivate = row.isPrivate;
101
+ const isArchived = row.isArchived;
102
+ const isFork = row.isFork;
103
+ const defaultBranch = row.defaultBranch;
104
+ const size = row.size;
105
+ const stargazersCount = row.stargazersCount;
106
+ const forksCount = row.forksCount;
107
+ const openIssuesCount = row.openIssuesCount;
108
+ const watchersCount = row.watchersCount;
109
+ const topics = row.topics;
110
+ const license = row.license;
111
+ const hasIssues = row.hasIssues;
112
+ const hasWiki = row.hasWiki;
113
+ const hasDiscussions = row.hasDiscussions;
114
+
115
+ return {
116
+ name: row.name,
117
+ fullName: row.fullName,
118
+ owner: row.owner,
119
+ description: row.description,
120
+ htmlUrl: htmlUrl ?? '',
121
+ sshUrl: sshUrl ?? '',
122
+ cloneUrl: cloneUrl ?? '',
123
+ isPrivate: isPrivate ?? false,
124
+ isArchived: isArchived ?? false,
125
+ isFork: isFork ?? false,
126
+ pushedAt: row.pushedAt,
127
+ updatedAt: row.updatedAt,
128
+ defaultBranch: defaultBranch ?? 'main',
129
+ language: row.language,
130
+ size: size ?? 0,
131
+ stargazersCount: stargazersCount ?? 0,
132
+ forksCount: forksCount ?? 0,
133
+ openIssuesCount: openIssuesCount ?? 0,
134
+ watchersCount: watchersCount ?? 0,
135
+ topics: (() => {
136
+ try {
137
+ return topics ? JSON.parse(topics) : [];
138
+ } catch (e) {
139
+ console.warn(`Failed to parse topics JSON: "${topics}"`);
140
+ return [];
141
+ }
142
+ })(),
143
+ license: license,
144
+ hasIssues: hasIssues ?? false,
145
+ hasWiki: hasWiki ?? false,
146
+ hasDiscussions: hasDiscussions ?? false,
147
+ };
148
+ });
149
+ } catch (error) {
150
+ console.error("Failed to load GitHub repos from cache:", error);
151
+ return [];
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Fetch GitHub repos with caching support
157
+ */
158
+ export async function fetchGitHubReposWithCache(
159
+ options?: {
160
+ includeOrgs?: boolean;
161
+ includeArchived?: boolean;
162
+ includeForks?: boolean;
163
+ },
164
+ cacheTtlSeconds = 300,
165
+ deps?: Partial<CacheDeps>
166
+ ): Promise<{
167
+ repos: GitHubRepoInfo[];
168
+ fromCache: boolean;
169
+ error?: string;
170
+ }> {
171
+ const githubService = deps?.githubService ?? defaultGitHubService;
172
+ const toGitHubRepoInfo = deps?.toGitHubRepoInfo ?? defaultToGitHubRepoInfo;
173
+ const tokenProvider = deps?.tokenProvider ?? getTokenAsync;
174
+
175
+ // Attempt to populate env token from gh CLI if none is set
176
+ if (!githubService.hasToken()) {
177
+ const token = await tokenProvider();
178
+ if (!token && !githubService.hasToken()) {
179
+ return { repos: [], fromCache: false, error: "GITHUB_TOKEN not set" };
180
+ }
181
+ }
182
+
183
+ // Try to load from cache first
184
+ try {
185
+ const cached = await loadGitHubReposFromCache({ db: deps?.db });
186
+ if (cached.length > 0 && isGitHubCacheFresh(cached[0]?.updatedAt || null, cacheTtlSeconds)) {
187
+ return { repos: cached, fromCache: true };
188
+ }
189
+ } catch (error) {
190
+ console.error("Failed to check GitHub cache:", error);
191
+ }
192
+
193
+ // Fetch fresh data
194
+ try {
195
+ const rawRepos = await githubService.getAllRepos(options);
196
+ const repos = rawRepos.map(toGitHubRepoInfo);
197
+
198
+ // Save to cache (don't await, let it happen in background)
199
+ saveGitHubReposToCache(repos, { db: deps?.db }).catch(console.error);
200
+
201
+ return { repos, fromCache: false };
202
+ } catch (error) {
203
+ const errorString = errorToString(error);
204
+ console.error("Failed to fetch GitHub repos:", errorString);
205
+
206
+ // On error, try to return stale cache if available
207
+ try {
208
+ const cached = await loadGitHubReposFromCache({ db: deps?.db });
209
+ if (cached.length > 0) {
210
+ console.log("Using stale cache due to fetch error");
211
+ return { repos: cached, fromCache: true, error: errorString };
212
+ }
213
+ } catch {
214
+ // No cache available
215
+ }
216
+
217
+ return { repos: [], fromCache: false, error: errorString };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Clear all GitHub repos from cache
223
+ */
224
+ export async function clearGitHubCache(): Promise<void> {
225
+ try {
226
+ const db = await initDb();
227
+ await db.delete(schema.githubRepos).run();
228
+ } catch (error) {
229
+ console.error("Failed to clear GitHub cache:", error);
230
+ }
231
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * GitHub URL parsing utilities
3
+ */
4
+
5
+ /**
6
+ * Get the GitHub repo name from a remote URL
7
+ */
8
+ export function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
9
+ // SSH format: git@github.com:owner/repo.git
10
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
11
+ if (sshMatch) {
12
+ return { owner: sshMatch[1]!, repo: sshMatch[2]! };
13
+ }
14
+
15
+ // HTTPS format: https://github.com/owner/repo.git
16
+ const httpsMatch = url.match(/https:\/\/github\.com\/([^/]+)\/(.+?)(\.git)?$/);
17
+ if (httpsMatch) {
18
+ return { owner: httpsMatch[1]!, repo: httpsMatch[2]! };
19
+ }
20
+
21
+ return null;
22
+ }