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.
- package/.bunignore +7 -0
- package/.github/workflows/ci.yml +73 -0
- package/CLAUDE.md +111 -0
- package/CONTRIBUTING.md +145 -0
- package/README.md +168 -0
- package/bun.lock +267 -0
- package/bunfig.toml +15 -0
- package/cli +0 -0
- package/config/gitforest.example.yaml +94 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
- package/docs/ai/VERIFICATION_REPORT.md +87 -0
- package/docs/ai/architecture.md +169 -0
- package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
- package/docs/ai/checks/check-2025-12-02.md +55 -0
- package/docs/ai/checks/test-verification-report.md +85 -0
- package/docs/ai/implementation-guide.md +776 -0
- package/docs/ai/research/gitty-codebase-analysis.md +221 -0
- package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
- package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
- package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
- package/docs/ai/tickets/TASK-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
- package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
- package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
- package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
- package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
- package/docs/ai/tkt-001-fix-database-error.md +217 -0
- package/docs/ai/ui-enhancement-plan.md +562 -0
- package/package.json +50 -0
- package/src/app.tsx +43 -0
- package/src/cli/config.ts +94 -0
- package/src/cli/formatters.ts +632 -0
- package/src/cli/index.ts +583 -0
- package/src/components/CloneDialog.tsx +137 -0
- package/src/components/ColumnHeader.tsx +128 -0
- package/src/components/CommandPalette.tsx +120 -0
- package/src/components/ConfirmDialog.tsx +105 -0
- package/src/components/ErrorBoundary.tsx +128 -0
- package/src/components/FilterBar.tsx +71 -0
- package/src/components/FilterOptionsOverlay.tsx +131 -0
- package/src/components/HelpOverlay.tsx +120 -0
- package/src/components/Layout.tsx +379 -0
- package/src/components/MarkdownRenderer.tsx +127 -0
- package/src/components/ProgressBar.tsx +53 -0
- package/src/components/ProjectItem.tsx +143 -0
- package/src/components/ProjectList.tsx +90 -0
- package/src/components/RepoDetailModal.tsx +367 -0
- package/src/components/StatusBar.tsx +188 -0
- package/src/components/UnifiedProjectItem.tsx +436 -0
- package/src/components/ViewModeIndicator.tsx +37 -0
- package/src/components/onboarding/CompleteStep.tsx +82 -0
- package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
- package/src/components/onboarding/DirectoriesStep.tsx +847 -0
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
- package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
- package/src/components/onboarding/OnboardingWizard.tsx +130 -0
- package/src/components/onboarding/WelcomeStep.tsx +69 -0
- package/src/config/loader.ts +263 -0
- package/src/config/onboarding.ts +67 -0
- package/src/constants.ts +96 -0
- package/src/db/index.ts +147 -0
- package/src/db/schema.ts +70 -0
- package/src/git/commands.ts +283 -0
- package/src/git/index.ts +2 -0
- package/src/git/operations.ts +93 -0
- package/src/git/service.ts +539 -0
- package/src/git/status.ts +84 -0
- package/src/git/types.ts +5 -0
- package/src/github/auth.ts +311 -0
- package/src/github/cache.ts +231 -0
- package/src/github/cli.ts +22 -0
- package/src/github/unified.ts +415 -0
- package/src/hooks/useBackgroundFetch.ts +76 -0
- package/src/hooks/useConfirmDialogActions.ts +120 -0
- package/src/hooks/useKeyBindings.ts +656 -0
- package/src/hooks/useProjects.ts +47 -0
- package/src/hooks/useUnifiedRepos.ts +317 -0
- package/src/index.tsx +494 -0
- package/src/operations/batch.ts +280 -0
- package/src/operations/commands.ts +140 -0
- package/src/operations/index.ts +37 -0
- package/src/scanner/index.ts +424 -0
- package/src/scanner/markers.ts +43 -0
- package/src/scanner/submodules.ts +61 -0
- package/src/services/git.ts +484 -0
- package/src/services/github.ts +676 -0
- package/src/services/index.ts +28 -0
- package/src/services/types.ts +99 -0
- package/src/state/actions.ts +175 -0
- package/src/state/reducer.ts +294 -0
- package/src/state/store.tsx +216 -0
- package/src/state/types.ts +8 -0
- package/src/types/index.ts +383 -0
- package/src/ui/theme.ts +44 -0
- package/src/utils/array.ts +14 -0
- package/src/utils/debug.ts +38 -0
- package/src/utils/errors.ts +17 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/markdown.ts +230 -0
- package/src/utils/project-utils.ts +129 -0
- package/src/utils/rate-limiter.ts +134 -0
- package/src/utils/retry.ts +147 -0
- package/src/utils/timeout.ts +56 -0
- package/test/integration/app.isolated.tsx +240 -0
- package/test/integration/cli-commands.test.ts +287 -0
- package/test/integration/cli-validation.test.ts +264 -0
- package/test/integration/git-operations.test.ts +218 -0
- package/test/integration/scanner.test.ts +228 -0
- package/test/preload.ts +18 -0
- package/test/unit/cli/commands.test.ts +13 -0
- package/test/unit/cli/formatters.test.ts +1116 -0
- package/test/unit/cli/github-commands.test.ts +12 -0
- package/test/unit/components/CloneDialog.test.tsx +240 -0
- package/test/unit/components/ColumnHeader.test.tsx +128 -0
- package/test/unit/components/CommandPalette.test.tsx +355 -0
- package/test/unit/components/ConfirmDialog.test.tsx +111 -0
- package/test/unit/components/ErrorBoundary.test.tsx +139 -0
- package/test/unit/components/FilterBar.test.tsx +43 -0
- package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
- package/test/unit/components/HelpOverlay.test.tsx +90 -0
- package/test/unit/components/Layout.test.tsx +328 -0
- package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
- package/test/unit/components/ProgressBar.test.tsx +138 -0
- package/test/unit/components/ProjectItem.test.tsx +182 -0
- package/test/unit/components/ProjectList.test.tsx +311 -0
- package/test/unit/components/RepoDetailModal.test.tsx +445 -0
- package/test/unit/components/StatusBar.test.tsx +112 -0
- package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
- package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
- package/test/unit/components/test-utils.tsx +63 -0
- package/test/unit/config/loader.test.ts +692 -0
- package/test/unit/db/database.test.ts +978 -0
- package/test/unit/db/index.test.ts +314 -0
- package/test/unit/fixtures/setup.ts +186 -0
- package/test/unit/git/commands-untested.test.ts +205 -0
- package/test/unit/git/commands.test.ts +269 -0
- package/test/unit/git/operations.test.ts +322 -0
- package/test/unit/git/status.test.ts +219 -0
- package/test/unit/github/auth.test.ts +317 -0
- package/test/unit/github/cache.test.ts +1028 -0
- package/test/unit/github/cli.test.ts +135 -0
- package/test/unit/github/unified.test.ts +1201 -0
- package/test/unit/graceful-shutdown.test.ts +83 -0
- package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
- package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
- package/test/unit/hooks/useProjects.test.tsx +186 -0
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
- package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
- package/test/unit/mocks/config.ts +109 -0
- package/test/unit/mocks/git-service.ts +274 -0
- package/test/unit/mocks/github-service.ts +250 -0
- package/test/unit/mocks/index.ts +72 -0
- package/test/unit/mocks/project.ts +148 -0
- package/test/unit/mocks/state-mocks.ts +187 -0
- package/test/unit/mocks/unified.ts +169 -0
- package/test/unit/operations/batch.test.ts +216 -0
- package/test/unit/operations/commands.test.ts +550 -0
- package/test/unit/scanner/errors.test.ts +297 -0
- package/test/unit/scanner/index.test.ts +1011 -0
- package/test/unit/scanner/markers.test.ts +150 -0
- package/test/unit/scanner/submodules.test.ts +99 -0
- package/test/unit/services/git-errors.test.ts +190 -0
- package/test/unit/services/git.test.ts +442 -0
- package/test/unit/services/github-errors.test.ts +293 -0
- package/test/unit/services/github.test.ts +200 -0
- package/test/unit/state/actions.test.ts +217 -0
- package/test/unit/state/reducer.test.ts +745 -0
- package/test/unit/state/store.test.tsx +711 -0
- package/test/unit/types/commands.test.ts +220 -0
- package/test/unit/types/schema.test.ts +179 -0
- package/test/unit/utils/array.test.ts +73 -0
- package/test/unit/utils/debug.test.ts +23 -0
- package/test/unit/utils/errors.test.ts +295 -0
- package/test/unit/utils/markdown.test.ts +163 -0
- package/test/unit/utils/project-utils.test.ts +756 -0
- package/test/unit/utils/rate-limiter.test.ts +256 -0
- package/test/unit/utils/retry.test.ts +165 -0
- package/test/unit/utils/strip-ansi.ts +13 -0
- package/test/unit/utils/timeout.test.ts +93 -0
- 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
|
+
}
|