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,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";
|