diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fff25ced..db50dfa4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -98,14 +98,7 @@ export const Header: React.FC = () => { }); setRepositories(mergedRepositories); - - // 3. 获取Release信息 - console.log('Fetching releases...'); - const releases = await githubApi.getMultipleRepositoryReleases(mergedRepositories.slice(0, 20)); - setReleases(releases); - setLastSync(new Date().toISOString()); - console.log('Sync completed successfully'); // 显示同步结果 const newRepoCount = newRepositories.length - repositories.length; @@ -360,18 +353,18 @@ export const Header: React.FC = () => { {/* User Actions */}
- {/* Sync Status */} -
- {t('上次同步:', 'Last sync:')} {formatLastSync(lastSync)} - -
+ {/* Sync Status */} +
+ {t('上次同步:', 'Last sync:')} {formatLastSync(lastSync)} + +
{/* Theme Toggle */} +
+ - {/* Search and Filters */}
@@ -640,12 +631,53 @@ export const ReleaseTimeline: React.FC = () => { {/* Filters and View Toggle Row */}
-
+ + + {/* Pre-release toggle + help icon (LEFT) */} +
+ + +
+ + {/* Refresh Button (RIGHT) */} +
+ +
+ +
+ + + +
{/* View Mode Toggle Dropdown */} @@ -807,7 +839,6 @@ export const ReleaseTimeline: React.FC = () => { )}
-
{/* Releases List */}
diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index d2a32643..db41b289 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -44,7 +44,45 @@ export class GitHubApiService { this.token = token; } + private rateLimitRemaining: number | null = null; + private rateLimitReset: number | null = null; + lastFetchFailures: { repoId: number; full_name: string; error: string }[] = []; + + private async controlledConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise + ): Promise<{ results: (R | null)[]; errors: { item: T; error: any }[] }> { + let index = 0; + const results: (R | null)[] = new Array(items.length).fill(null); + const errors: { item: T; error: any }[] = []; + + const worker = async () => { + while (true) { + const currentIndex = index++; + if (currentIndex >= items.length) break; + + try { + results[currentIndex] = await fn(items[currentIndex]); + } catch (error) { + errors.push({ item: items[currentIndex], error }); + } + } + }; + + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return { results, errors }; + } + private async makeRequest(endpoint: string, options: RequestInit = {}, signal?: AbortSignal): Promise { + if (this.rateLimitRemaining !== null && this.rateLimitRemaining < 3 && this.rateLimitReset !== null) { + const waitMs = (this.rateLimitReset * 1000) - Date.now(); + if (waitMs > 0) { + await new Promise(resolve => setTimeout(resolve, waitMs + 1000)); + } + } + const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { ...options, signal, @@ -56,19 +94,32 @@ export class GitHubApiService { }, }); + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + if (remaining !== null) { + this.rateLimitRemaining = parseInt(remaining, 10); + } + if (reset !== null) { + this.rateLimitReset = parseInt(reset, 10); + } + if (!response.ok) { if (response.status === 401) { throw new Error('GitHub token expired or invalid'); } + if (response.status === 403 && this.rateLimitRemaining === 0) { + const resetDate = this.rateLimitReset + ? new Date(this.rateLimitReset * 1000).toLocaleString() + : 'unknown'; + throw new Error(`GitHub API rate limit exceeded. Resets at ${resetDate}`); + } throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const data = response.status === 204 ? null : await response.json(); - // 如果是starred repositories的响应,需要处理特殊格式 if (endpoint.includes('/user/starred') && Array.isArray(data)) { return data.map((item: GitHubStarredItem) => { - // 如果使用了star+json格式,数据结构会不同 if (item.starred_at && item.repo) { return { ...item.repo, @@ -144,98 +195,143 @@ export class GitHubApiService { } async getRepositoryReleases(owner: string, repo: string, page = 1, perPage = 30): Promise { - try { - const releases = await this.makeRequest( - `/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}` - ); - - return releases.map(release => ({ - id: release.id, - tag_name: release.tag_name, - name: release.name || release.tag_name, - body: release.body || '', - published_at: release.published_at, - html_url: release.html_url, - assets: release.assets || [], - zipball_url: release.zipball_url, - tarball_url: release.tarball_url, - repository: { - id: 0, - full_name: `${owner}/${repo}`, - name: repo, - }, - })); - } catch (error) { - console.warn(`Failed to fetch releases for ${owner}/${repo}:`, error); - return []; - } + const releases = await this.makeRequest( + `/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}` + ); + + return releases.map(release => ({ + id: release.id, + tag_name: release.tag_name, + name: release.name || release.tag_name, + body: release.body || '', + published_at: release.published_at, + html_url: release.html_url, + assets: release.assets || [], + zipball_url: release.zipball_url, + tarball_url: release.tarball_url, + prerelease: release.prerelease ?? false, + repository: { + id: 0, + full_name: `${owner}/${repo}`, + name: repo, + }, + })); } - async getMultipleRepositoryReleases(repositories: Repository[]): Promise { + async getMultipleRepositoryReleases( + repositories: Repository[], + perPage = 30 + ): Promise<{ releases: Release[]; failedRepos: { repoId: number; full_name: string; error: string }[] }> { + const { results, errors } = await this.controlledConcurrency( + repositories, + 3, + async (repo) => { + const [owner, name] = repo.full_name.split('/'); + let releases: Release[]; + + if (!repo.hasFetchedReleases) { + // 新订阅仓库,全量获取 + releases = await this.getRepositoryReleases(owner, name, 1, perPage); + } else { + // 已有数据,增量获取 + const since = repo.lastReleaseSyncTime; + releases = await this.getIncrementalRepositoryReleases(owner, name, since, perPage); + } + + releases.forEach(release => { + release.repository.id = repo.id; + }); + return releases; + } + ); + const allReleases: Release[] = []; - - for (const repo of repositories) { - const [owner, name] = repo.full_name.split('/'); - const releases = await this.getRepositoryReleases(owner, name, 1, 5); - - // Add repository info to releases - releases.forEach(release => { - release.repository.id = repo.id; + const failedRepos: { repoId: number; full_name: string; error: string }[] = []; + + results.forEach((repoReleases, index) => { + if (repoReleases) { + allReleases.push(...repoReleases); + } + }); + + errors.forEach(({ item, error }) => { + const repo = item as Repository; + failedRepos.push({ + repoId: repo.id, + full_name: repo.full_name, + error: error instanceof Error ? error.message : String(error), }); - - allReleases.push(...releases); - - // Rate limiting protection - await new Promise(resolve => setTimeout(resolve, 150)); - } + }); - // Sort by published date (newest first) - return allReleases.sort((a, b) => + const sortedReleases = allReleases.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime() ); + + return { releases: sortedReleases, failedRepos }; } - // 新增:获取仓库的增量releases(基于时间戳) + // 获取仓库的增量releases(基于时间戳,使用分页+published_at截止) async getIncrementalRepositoryReleases( - owner: string, - repo: string, - since?: string, - perPage = 10 + owner: string, + repo: string, + since?: string, + perPage = 30 ): Promise { try { - const endpoint = `/repos/${owner}/${repo}/releases?per_page=${perPage}`; - - const releases = await this.makeRequest(endpoint); - - const mappedReleases = releases.map(release => ({ - id: release.id, - tag_name: release.tag_name, - name: release.name || release.tag_name, - body: release.body || '', - published_at: release.published_at, - html_url: release.html_url, - assets: release.assets || [], - zipball_url: release.zipball_url, - tarball_url: release.tarball_url, - repository: { - id: 0, - full_name: `${owner}/${repo}`, - name: repo, - }, - })); - - // 如果提供了since时间戳,只返回更新的releases - if (since) { - const sinceDate = new Date(since); - return mappedReleases.filter(release => - new Date(release.published_at) > sinceDate + // 如果没有 since,直接获取最新一页即可 + if (!since) { + return this.getRepositoryReleases(owner, repo, 1, perPage); + } + + const sinceDate = new Date(since); + const newReleases: Release[] = []; + let page = 1; + + while (true) { + if (page > 50) { + throw new Error(`Exceeded safety bound: page ${page} while paginating releases for ${owner}/${repo}`); + } + + const releases = await this.makeRequest( + `/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}` ); + + if (!releases || releases.length === 0) break; + + let foundCutoff = false; + for (const release of releases) { + const pubDate = new Date(release.published_at); + if (pubDate <= sinceDate) { + foundCutoff = true; + break; + } + newReleases.push({ + id: release.id, + tag_name: release.tag_name, + name: release.name || release.tag_name, + body: release.body || '', + published_at: release.published_at, + html_url: release.html_url, + assets: release.assets || [], + zipball_url: release.zipball_url, + tarball_url: release.tarball_url, + prerelease: release.prerelease ?? false, + repository: { + id: 0, + full_name: `${owner}/${repo}`, + name: repo, + }, + }); + } + + if (foundCutoff) break; + if (releases.length < perPage) break; + page++; } - return mappedReleases; + return newReleases; } catch (error) { - console.warn(`Failed to fetch incremental releases for ${owner}/${repo}:`, error); - return []; + throw error; } } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 9912a231..9d49d0e9 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -156,15 +156,16 @@ interface AppActions { // Backend actions setBackendApiSecret: (secret: string | null) => void; - // Release Timeline View actions - setReleaseViewMode: (mode: 'timeline' | 'repository') => void; - setReleaseSelectedFilters: (filters: string[]) => void; - toggleReleaseSelectedFilter: (filterId: string) => void; - clearReleaseSelectedFilters: () => void; - setReleaseSearchQuery: (query: string) => void; - toggleReleaseExpandedRepository: (repoId: number) => void; - setReleaseExpandedRepositories: (repoIds: Set) => void; - setReleaseIsRefreshing: (refreshing: boolean) => void; + // Release Timeline View actions + setReleaseViewMode: (mode: 'timeline' | 'repository') => void; + setReleaseSelectedFilters: (filters: string[]) => void; + toggleReleaseSelectedFilter: (filterId: string) => void; + clearReleaseSelectedFilters: () => void; + setReleaseSearchQuery: (query: string) => void; + toggleReleaseExpandedRepository: (repoId: number) => void; + setReleaseExpandedRepositories: (repoIds: Set) => void; + setReleaseIsRefreshing: (refreshing: boolean) => void; + setIncludePreRelease: (include: boolean) => void; // Discovery actions setSelectedDiscoveryChannel: (channel: DiscoveryChannelId) => void; @@ -276,19 +277,22 @@ const normalizePersistedState = ( const repositories = Array.isArray(safePersisted.repositories) ? safePersisted.repositories : []; const releases = Array.isArray(safePersisted.releases) ? safePersisted.releases : []; - return { - ...currentState, - ...safePersisted, - theme: - safePersisted.theme === 'light' || safePersisted.theme === 'dark' - ? safePersisted.theme - : 'dark', - repositories, - releases, - searchResults: repositories, - releaseSubscriptions: normalizeNumberSet(safePersisted.releaseSubscriptions), - readReleases: normalizeNumberSet(safePersisted.readReleases), - releaseExpandedRepositories: normalizeNumberSet(safePersisted.releaseExpandedRepositories), + const includePreRelease = safePersisted.includePreRelease ?? true; + + return { + ...currentState, + ...safePersisted, + includePreRelease, + theme: + safePersisted.theme === 'light' || safePersisted.theme === 'dark' + ? safePersisted.theme + : 'dark', + repositories, + releases, + searchResults: repositories, + releaseSubscriptions: normalizeNumberSet(safePersisted.releaseSubscriptions), + readReleases: normalizeNumberSet(safePersisted.readReleases), + releaseExpandedRepositories: normalizeNumberSet(safePersisted.releaseExpandedRepositories), searchFilters: { ...initialSearchFilters, ...safePersisted.searchFilters, @@ -651,11 +655,12 @@ export const useAppStore = create()( backendApiSecret: readSessionBackendSecret(), isSidebarCollapsed: false, readmeModalOpen: false, - releaseViewMode: 'timeline', - releaseSelectedFilters: [], - releaseSearchQuery: '', - releaseExpandedRepositories: new Set(), - releaseIsRefreshing: false, + releaseViewMode: 'timeline', + releaseSelectedFilters: [], + releaseSearchQuery: '', + releaseExpandedRepositories: new Set(), + releaseIsRefreshing: false, + includePreRelease: true, discoveryChannels: defaultDiscoveryChannels, discoveryRepos: { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] }, @@ -1180,7 +1185,8 @@ export const useAppStore = create()( return { releaseExpandedRepositories: newSet }; }), setReleaseExpandedRepositories: (releaseExpandedRepositories) => set({ releaseExpandedRepositories }), - setReleaseIsRefreshing: (releaseIsRefreshing) => set({ releaseIsRefreshing }), + setReleaseIsRefreshing: (releaseIsRefreshing) => set({ releaseIsRefreshing }), + setIncludePreRelease: (includePreRelease) => set({ includePreRelease }), // Discovery actions setSelectedDiscoveryChannel: (selectedDiscoveryChannel) => set((state) => ({ diff --git a/src/types/index.ts b/src/types/index.ts index 57be05c7..b649cee4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,11 +23,13 @@ export interface Repository { analyzed_at?: string; analysis_failed?: boolean; subscribed_to_releases?: boolean; - custom_description?: string; - custom_tags?: string[]; - custom_category?: string; - category_locked?: boolean; - last_edited?: string; + custom_description?: string; + custom_tags?: string[]; + custom_category?: string; + category_locked?: boolean; + last_edited?: string; + lastReleaseSyncTime?: string; + hasFetchedReleases?: boolean; } export interface ReleaseAsset { @@ -51,6 +53,7 @@ export interface Release { assets: ReleaseAsset[]; zipball_url?: string; tarball_url?: string; + prerelease?: boolean; repository: { id: number; full_name: string;