From 6ed16258fd9d258c41bf2e7a52ad020e31135b10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 23:48:52 +0000 Subject: [PATCH 1/3] refactor: Consolidate utility files for simpler architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-impact restructuring to reduce complexity and improve maintainability: CONSOLIDATED UTILITIES (24 files → 6 files): - summary.ts: Merged summaryConstants, summaryGrouping, summaryHelpers - filtering.ts: Merged filterUtils, viewFiltering, resultsUtils - githubData.ts: Merged rawDataUtils, prEnrichment - storage.ts: Merged storageUtils, indexedDB, usernameCache BENEFITS: - Fewer files to navigate (easier to find code) - Logical grouping by domain (summary, filtering, data, storage) - Cleaner imports (one import instead of many) - Foundation for fast iterations All imports updated across codebase and test files. --- src/components/SettingsDialog.tsx | 3 +- src/hooks/useGitHubDataFetching.ts | 2 +- src/hooks/useGitHubDataProcessing.ts | 5 +- src/hooks/useGitHubFormState.ts | 8 +- src/hooks/useIndexedDBStorage.ts | 2 +- src/test/IndexedDBStorage.test.tsx | 2 +- src/utils/__tests__/prEnrichment.test.ts | 2 +- src/utils/__tests__/rawDataUtils.test.ts | 2 +- src/utils/__tests__/summaryConstants.test.ts | 8 +- src/utils/__tests__/summaryGrouping.test.ts | 4 +- src/utils/__tests__/summaryHelpers.test.ts | 2 +- src/utils/__tests__/viewFiltering.test.ts | 2 +- src/utils/filterUtils.test.ts | 2 +- src/utils/filterUtils.ts | 130 --- src/utils/{resultsUtils.ts => filtering.ts} | 657 +++++++++------ src/utils/{rawDataUtils.ts => githubData.ts} | 410 +++++++--- src/utils/githubSearch.test.ts | 4 +- src/utils/githubSearch.ts | 2 +- src/utils/indexedDB.ts | 386 --------- src/utils/prEnrichment.ts | 239 ------ src/utils/resultsUtils.test.ts | 2 +- src/utils/storage.ts | 806 +++++++++++++++++++ src/utils/storageUtils.ts | 244 ------ src/utils/{summaryGrouping.ts => summary.ts} | 205 ++++- src/utils/summaryConstants.ts | 29 - src/utils/summaryHelpers.ts | 83 -- src/utils/urlState.test.ts | 2 +- src/utils/urlState.ts | 2 +- src/utils/usernameCache.test.ts | 2 +- src/utils/usernameCache.ts | 279 ------- src/utils/viewFiltering.ts | 116 --- src/views/EventView.tsx | 2 +- src/views/IssuesAndPRsList.tsx | 3 +- src/views/Summary.tsx | 17 +- 34 files changed, 1722 insertions(+), 1942 deletions(-) delete mode 100644 src/utils/filterUtils.ts rename src/utils/{resultsUtils.ts => filtering.ts} (52%) rename src/utils/{rawDataUtils.ts => githubData.ts} (66%) delete mode 100644 src/utils/indexedDB.ts delete mode 100644 src/utils/prEnrichment.ts create mode 100644 src/utils/storage.ts delete mode 100644 src/utils/storageUtils.ts rename src/utils/{summaryGrouping.ts => summary.ts} (69%) delete mode 100644 src/utils/summaryConstants.ts delete mode 100644 src/utils/summaryHelpers.ts delete mode 100644 src/utils/usernameCache.ts delete mode 100644 src/utils/viewFiltering.ts diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index df22ec0..fdfcc18 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -13,8 +13,7 @@ import { import { useLocalStorage } from '../hooks/useLocalStorage'; import { useFormContext } from '../App'; import { SettingsDialogProps } from '../types'; -import { clearAllGitHubData } from '../utils/storageUtils'; -import { eventsStorage } from '../utils/indexedDB'; +import { clearAllGitHubData, eventsStorage } from '../utils/storage'; const SettingsDialog = memo(function SettingsDialog({ isOpen, diff --git a/src/hooks/useGitHubDataFetching.ts b/src/hooks/useGitHubDataFetching.ts index 54d53bf..4e2b989 100644 --- a/src/hooks/useGitHubDataFetching.ts +++ b/src/hooks/useGitHubDataFetching.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { GitHubEvent, GitHubItem } from '../types'; -import { EventsData } from '../utils/indexedDB'; +import { EventsData } from '../utils/storage'; import { validateUsernameList, isValidDateString } from '../utils'; import { MAX_USERNAMES_PER_REQUEST, GITHUB_API_PER_PAGE, GITHUB_API_DELAY_MS } from '../utils/settings'; diff --git a/src/hooks/useGitHubDataProcessing.ts b/src/hooks/useGitHubDataProcessing.ts index a9d5526..b5c5a8a 100644 --- a/src/hooks/useGitHubDataProcessing.ts +++ b/src/hooks/useGitHubDataProcessing.ts @@ -1,8 +1,7 @@ import { useMemo, useState, useEffect } from 'react'; import { GitHubEvent, GitHubItem } from '../types'; -import { processRawEvents, categorizeRawSearchItems } from '../utils/rawDataUtils'; -import { filterItemsByAdvancedSearch } from '../utils/viewFiltering'; -import { enrichItemsWithPRDetails } from '../utils/prEnrichment'; +import { processRawEvents, categorizeRawSearchItems, enrichItemsWithPRDetails } from '../utils/githubData'; +import { filterItemsByAdvancedSearch } from '../utils/filtering'; interface UseGitHubDataProcessingProps { indexedDBEvents: GitHubEvent[]; diff --git a/src/hooks/useGitHubFormState.ts b/src/hooks/useGitHubFormState.ts index 27d8ca0..bde37f3 100644 --- a/src/hooks/useGitHubFormState.ts +++ b/src/hooks/useGitHubFormState.ts @@ -2,14 +2,14 @@ import { useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { validateUsernameList } from '../utils'; import { useFormSettings } from './useLocalStorage'; import { FormSettings } from '../types'; -import { +import { validateGitHubUsernames, - type BatchValidationResult + type BatchValidationResult } from '../utils'; -import { +import { getCachedAvatarUrls, createAddAvatarsToCache -} from '../utils/usernameCache'; +} from '../utils/storage'; import { useLocalStorage } from './useLocalStorage'; interface UseGitHubFormStateReturn { diff --git a/src/hooks/useIndexedDBStorage.ts b/src/hooks/useIndexedDBStorage.ts index b66f6ee..d3c6c4d 100644 --- a/src/hooks/useIndexedDBStorage.ts +++ b/src/hooks/useIndexedDBStorage.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { eventsStorage, type EventsData } from '../utils/indexedDB'; +import { eventsStorage, type EventsData } from '../utils/storage'; import { GitHubEvent } from '../types'; interface UseIndexedDBStorageReturn { diff --git a/src/test/IndexedDBStorage.test.tsx b/src/test/IndexedDBStorage.test.tsx index 3c16bfe..4943cd1 100644 --- a/src/test/IndexedDBStorage.test.tsx +++ b/src/test/IndexedDBStorage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useIndexedDBStorage } from '../hooks/useIndexedDBStorage'; -import { eventsStorage } from '../utils/indexedDB'; +import { eventsStorage } from '../utils/storage'; import { GitHubEvent } from '../types'; // Mock IndexedDB diff --git a/src/utils/__tests__/prEnrichment.test.ts b/src/utils/__tests__/prEnrichment.test.ts index 0ddb53e..ad73402 100644 --- a/src/utils/__tests__/prEnrichment.test.ts +++ b/src/utils/__tests__/prEnrichment.test.ts @@ -5,7 +5,7 @@ import { enrichItemsWithPRDetails, clearPRCache, getPRCacheSize, -} from '../prEnrichment'; +} from '../githubData'; import { GitHubItem } from '../../types'; // Mock fetch globally diff --git a/src/utils/__tests__/rawDataUtils.test.ts b/src/utils/__tests__/rawDataUtils.test.ts index 02eb065..ec1f1f7 100644 --- a/src/utils/__tests__/rawDataUtils.test.ts +++ b/src/utils/__tests__/rawDataUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { transformEventToItem, processRawEvents, categorizeRawSearchItems } from '../rawDataUtils'; +import { transformEventToItem, processRawEvents, categorizeRawSearchItems } from '../githubData'; import { GitHubEvent, GitHubItem } from '../../types'; describe('rawDataUtils', () => { diff --git a/src/utils/__tests__/summaryConstants.test.ts b/src/utils/__tests__/summaryConstants.test.ts index d81f5fd..88a73b8 100644 --- a/src/utils/__tests__/summaryConstants.test.ts +++ b/src/utils/__tests__/summaryConstants.test.ts @@ -1,8 +1,8 @@ -import { - SUMMARY_GROUP_NAMES, - getAllGroupNames, +import { + SUMMARY_GROUP_NAMES, + getAllGroupNames, createEmptyGroups -} from '../summaryConstants'; +} from '../summary'; describe('summaryConstants', () => { describe('SUMMARY_GROUP_NAMES', () => { diff --git a/src/utils/__tests__/summaryGrouping.test.ts b/src/utils/__tests__/summaryGrouping.test.ts index aacf3c3..f848dc4 100644 --- a/src/utils/__tests__/summaryGrouping.test.ts +++ b/src/utils/__tests__/summaryGrouping.test.ts @@ -4,8 +4,8 @@ import { categorizeItemWithoutDateFiltering, groupSummaryData, isDateInRange, -} from '../summaryGrouping'; -import { SUMMARY_GROUP_NAMES } from '../summaryConstants'; + SUMMARY_GROUP_NAMES, +} from '../summary'; import { GitHubItem } from '../../types'; describe('categorizeItem - PRs Updated', () => { diff --git a/src/utils/__tests__/summaryHelpers.test.ts b/src/utils/__tests__/summaryHelpers.test.ts index d08a874..fb5b7c2 100644 --- a/src/utils/__tests__/summaryHelpers.test.ts +++ b/src/utils/__tests__/summaryHelpers.test.ts @@ -5,7 +5,7 @@ import { getTotalItemCount, isSectionCollapsed, getGroupSelectState -} from '../summaryHelpers'; +} from '../summary'; import { GitHubItem } from '../../types'; // Mock data helper diff --git a/src/utils/__tests__/viewFiltering.test.ts b/src/utils/__tests__/viewFiltering.test.ts index 1bbdff2..c0624ce 100644 --- a/src/utils/__tests__/viewFiltering.test.ts +++ b/src/utils/__tests__/viewFiltering.test.ts @@ -3,7 +3,7 @@ import { sortItemsByUpdatedDate, parseCommaSeparatedUsernames, isItemAuthoredBySearchedUsers -} from '../viewFiltering'; +} from '../filtering'; import { GitHubItem } from '../../types'; // Mock data helper diff --git a/src/utils/filterUtils.test.ts b/src/utils/filterUtils.test.ts index c2b74e3..6a05c0f 100644 --- a/src/utils/filterUtils.test.ts +++ b/src/utils/filterUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { countItemsMatchingFilter, FilterType } from './filterUtils'; +import { countItemsMatchingFilter, FilterType } from './filtering'; import { GitHubItem } from '../types'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/src/utils/filterUtils.ts b/src/utils/filterUtils.ts deleted file mode 100644 index 029707f..0000000 --- a/src/utils/filterUtils.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { GitHubItem } from '../types'; - -/** - * Supported filter types for counting GitHub items - */ -export type FilterType = 'type' | 'status' | 'label' | 'repo'; - -/** - * Filter values for different filter types - */ -export type FilterValue = string; - -/** - * Options for filtering GitHub items - */ -export interface FilterOptions { - /** The type of filter to apply */ - filterType: FilterType; - /** The value to filter by */ - filterValue: FilterValue; - /** Array of label names to exclude from results */ - excludedLabels: string[]; -} - -/** - * Counts the number of GitHub items that match the specified filter criteria. - * - * This function supports filtering by: - * - **Type**: Filter by issue ('issue') or pull request ('pr'), or show all ('all') - * - **Status**: Filter by open ('open'), closed ('closed'), merged ('merged'), or show all ('all') - * - **Label**: Filter by items that have a specific label name - * - **Repository**: Filter by items from a specific repository - * - * @param items - Array of GitHub items to filter and count - * @param filterType - The type of filter to apply ('type', 'status', 'label', 'repo') - * @param filterValue - The value to filter by (depends on filterType) - * @param excludedLabels - Array of label names to exclude from label filtering - * @returns The number of items that match the filter criteria - * - * @example - * ```typescript - * // Count all pull requests - * const prCount = countItemsMatchingFilter(items, 'type', 'pr', []); - * - * // Count open issues - * const openCount = countItemsMatchingFilter(items, 'status', 'open', []); - * - * // Count items with 'bug' label, excluding 'wontfix' labeled items - * const bugCount = countItemsMatchingFilter(items, 'label', 'bug', ['wontfix']); - * - * // Count items from specific repository - * const repoCount = countItemsMatchingFilter(items, 'repo', 'owner/repo-name', []); - * ``` - */ -export const countItemsMatchingFilter = ( - items: GitHubItem[], - filterType: FilterType, - filterValue: FilterValue, - excludedLabels: string[] -): number => { - if (!Array.isArray(items)) { - return 0; - } - - switch (filterType) { - case 'type': - return items.filter(item => { - if (filterValue === 'all') return true; - - // Check if this is a comment event - const isComment = item.title.startsWith('Comment on:'); - - if (filterValue === 'pr') { - return !!item.pull_request && !isComment; - } - if (filterValue === 'issue') { - return !item.pull_request && !isComment; - } - if (filterValue === 'comment') { - return isComment; - } - return false; - }).length; - - case 'status': - if (filterValue === 'merged') { - return items.filter( - item => - item.pull_request && (item.pull_request.merged_at || item.merged) - ).length; - } - return items.filter(item => { - if (filterValue === 'all') return true; - - // For pull requests, check if they're merged first - if (item.pull_request) { - if (item.pull_request.merged_at || item.merged) return false; - return item.state === filterValue; - } - - // For issues, just check the state - return item.state === filterValue; - }).length; - - case 'label': - return items.filter(item => { - // Must have the specified label - const hasLabel = item.labels?.some(l => l.name === filterValue); - if (!hasLabel) return false; - - // Must not have any excluded labels - const hasExcludedLabel = item.labels?.some(l => - excludedLabels.includes(l.name) - ); - return !hasExcludedLabel; - }).length; - - case 'repo': - return items.filter(item => { - const itemRepo = item.repository_url?.replace( - 'https://api.github.com/repos/', - '' - ); - return itemRepo === filterValue; - }).length; - - default: - return 0; - } -}; diff --git a/src/utils/resultsUtils.ts b/src/utils/filtering.ts similarity index 52% rename from src/utils/resultsUtils.ts rename to src/utils/filtering.ts index 76d3a66..8ca92c7 100644 --- a/src/utils/resultsUtils.ts +++ b/src/utils/filtering.ts @@ -1,23 +1,36 @@ +/** + * Filtering Utilities + * + * Consolidates all filtering and search functionality: + * - Type, status, label, repo, and user filters + * - Advanced search text parsing (label:, user:, repo:, -label:, -repo:) + * - Sorting and counting utilities + */ + import { GitHubItem } from '../types'; +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + /** - * Results Utilities - * - * Provides functions for filtering, sorting, and manipulating GitHub items results. + * Supported filter types for counting GitHub items */ +export type FilterType = 'type' | 'status' | 'label' | 'repo'; -// Cache for parseSearchText to avoid repeated regex parsing -const parseSearchTextCache = new Map(); +/** + * Filter values for different filter types + */ +export type FilterValue = string; -// Limit cache size to prevent memory leaks -const MAX_CACHE_SIZE = 100; +/** + * Options for filtering GitHub items (legacy interface for countItemsMatchingFilter) + */ +export interface FilterOptions { + filterType: FilterType; + filterValue: FilterValue; + excludedLabels: string[]; +} /** * Filter configuration for GitHub items @@ -40,39 +53,157 @@ export interface ResultsFilter { } /** - * Extracts unique labels from an array of GitHub items + * Parsed search text result + */ +export interface ParsedSearchText { + includedLabels: string[]; + excludedLabels: string[]; + userFilters: string[]; + includedRepos: string[]; + excludedRepos: string[]; + cleanText: string; +} + +// ============================================================================ +// SEARCH TEXT PARSING (with caching) +// ============================================================================ + +// Cache for parseSearchText to avoid repeated regex parsing +const parseSearchTextCache = new Map(); +const MAX_CACHE_SIZE = 100; + +/** + * Parses search text to extract label filters, user filters, repo filters, and regular text * - * @param items - Array of GitHub items - * @returns Array of unique label names sorted alphabetically + * Supports syntax: + * - label:{name} - include items with this label + * - -label:{name} - exclude items with this label + * - user:{username} - filter by user + * - repo:{owner/repo} - include items from this repo + * - -repo:{owner/repo} - exclude items from this repo */ -export const extractAvailableLabels = (items: GitHubItem[]): string[] => { - const labels = new Set(); +export const parseSearchText = (searchText: string): ParsedSearchText => { + if (!searchText.trim()) { + return { + includedLabels: [], + excludedLabels: [], + userFilters: [], + includedRepos: [], + excludedRepos: [], + cleanText: '', + }; + } - if (!Array.isArray(items)) { - return []; + // Check cache first + const cached = parseSearchTextCache.get(searchText); + if (cached) { + return cached; + } + + const includedLabels: string[] = []; + const excludedLabels: string[] = []; + const userFilters: string[] = []; + const includedRepos: string[] = []; + const excludedRepos: string[] = []; + let cleanText = searchText; + + // First, find all -label:{labelname} patterns (excluded labels) + const excludeLabelRegex = /-label:([^\s]+)/g; + let match; + const excludeMatches: RegExpExecArray[] = []; + while ((match = excludeLabelRegex.exec(searchText)) !== null) { + excludedLabels.push(match[1]); + excludeMatches.push(match); } - items.forEach(item => { - item.labels?.forEach(label => labels.add(label.name)); + // Remove all excluded label matches from cleanText + excludeMatches.forEach((m) => { + cleanText = cleanText.replace(m[0], ' '); }); - return Array.from(labels).sort((a, b) => - a.toLowerCase().localeCompare(b.toLowerCase()) - ); + // Then find all label:{labelname} patterns (included labels) from the cleaned text + const includeLabelRegex = /\blabel:([^\s]+)/g; + const includeMatches: RegExpExecArray[] = []; + while ((match = includeLabelRegex.exec(cleanText)) !== null) { + includedLabels.push(match[1]); + includeMatches.push(match); + } + + // Remove all included label matches from cleanText + includeMatches.forEach((m) => { + cleanText = cleanText.replace(m[0], ' '); + }); + + // Then find all user:{username} patterns from the cleaned text + const userRegex = /\buser:([^\s]+)/g; + const userMatches: RegExpExecArray[] = []; + while ((match = userRegex.exec(cleanText)) !== null) { + userFilters.push(match[1]); + userMatches.push(match); + } + + // Remove all user matches from cleanText + userMatches.forEach((m) => { + cleanText = cleanText.replace(m[0], ' '); + }); + + // Then find all -repo:{reponame} patterns (excluded repos) + const excludeRepoRegex = /-repo:([^\s]+)/g; + const excludeRepoMatches: RegExpExecArray[] = []; + while ((match = excludeRepoRegex.exec(cleanText)) !== null) { + excludedRepos.push(match[1]); + excludeRepoMatches.push(match); + } + + // Remove all excluded repo matches from cleanText + excludeRepoMatches.forEach((m) => { + cleanText = cleanText.replace(m[0], ' '); + }); + + // Then find all repo:{reponame} patterns from the cleaned text + const includeRepoRegex = /\brepo:([^\s]+)/g; + const includeRepoMatches: RegExpExecArray[] = []; + while ((match = includeRepoRegex.exec(cleanText)) !== null) { + includedRepos.push(match[1]); + includeRepoMatches.push(match); + } + + // Remove all included repo matches from cleanText + includeRepoMatches.forEach((m) => { + cleanText = cleanText.replace(m[0], ' '); + }); + + // Clean up extra whitespace + cleanText = cleanText.replace(/\s+/g, ' ').trim(); + + const result = { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText }; + + // Cache the result + if (parseSearchTextCache.size >= MAX_CACHE_SIZE) { + // Remove oldest entry (first in Map) + const firstKey = parseSearchTextCache.keys().next().value; + if (firstKey !== undefined) { + parseSearchTextCache.delete(firstKey); + } + } + parseSearchTextCache.set(searchText, result); + + return result; }; +// ============================================================================ +// ITEM TYPE DETECTION +// ============================================================================ + /** * Determines the type of a GitHub item (issue, pull request, or comment) - * - * @param item - GitHub item to analyze - * @returns The item type */ export const getItemType = (item: GitHubItem): 'issue' | 'pr' | 'comment' => { - // Check if this is a pull request review (title starts with "Reviewed:") + // Check if this is a pull request review if (item.title.startsWith('Reviewed:')) { return 'pr'; } - // Check if this is a comment event (title starts with "Comment on:") + // Check if this is a comment event if (item.title.startsWith('Comment on:')) { return 'comment'; } @@ -84,12 +215,26 @@ export const getItemType = (item: GitHubItem): 'issue' | 'pr' | 'comment' => { return 'issue'; }; +/** + * Checks if a GitHub item is merged + */ +export const isMerged = (item: GitHubItem): boolean => { + return !!(item.pull_request && (item.pull_request.merged_at || item.merged)); +}; + +/** + * Gets the repository name from a GitHub item + */ +export const getRepositoryName = (item: GitHubItem): string | undefined => { + return item.repository_url?.replace('https://api.github.com/repos/', ''); +}; + +// ============================================================================ +// INDIVIDUAL FILTERS +// ============================================================================ + /** * Filters GitHub items based on type (issue vs pull request vs comment) - * - * @param items - Array of GitHub items to filter - * @param filter - Type filter ('all', 'issue', 'pr', 'comment') - * @returns Filtered array of items */ export const filterByType = ( items: GitHubItem[], @@ -97,7 +242,7 @@ export const filterByType = ( ): GitHubItem[] => { if (filter === 'all') return items; - return items.filter(item => { + return items.filter((item) => { const itemType = getItemType(item); if (filter === 'pr') { @@ -114,10 +259,6 @@ export const filterByType = ( /** * Filters GitHub items based on status (open, closed, merged) - * - * @param items - Array of GitHub items to filter - * @param statusFilter - Status filter ('all', 'open', 'closed', 'merged') - * @returns Filtered array of items */ export const filterByStatus = ( items: GitHubItem[], @@ -126,14 +267,12 @@ export const filterByStatus = ( if (statusFilter === 'all') return items; if (statusFilter === 'merged') { - return items.filter( - item => item.pull_request && (item.pull_request.merged_at || item.merged) - ); + return items.filter((item) => isMerged(item)); } - return items.filter(item => { + return items.filter((item) => { // For pull requests, exclude merged ones when filtering by open/closed - if (item.pull_request && (item.pull_request.merged_at || item.merged)) { + if (isMerged(item)) { return false; } return item.state === statusFilter; @@ -142,26 +281,20 @@ export const filterByStatus = ( /** * Filters GitHub items based on label criteria - * - * @param items - Array of GitHub items to filter - * @param includedLabels - Array of label names to include (empty array means no filter) - * @param excludedLabels - Array of label names to exclude - * @returns Filtered array of items */ export const filterByLabels = ( items: GitHubItem[], includedLabels: string[] | undefined, excludedLabels: string[] | undefined ): GitHubItem[] => { - // Provide default empty arrays if parameters are undefined const safeIncludedLabels = includedLabels || []; const safeExcludedLabels = excludedLabels || []; - return items.filter(item => { + return items.filter((item) => { // Apply inclusive label filter - item must have ALL included labels if (safeIncludedLabels.length > 0) { - const itemLabels = item.labels?.map(l => l.name) || []; - const hasAllIncludedLabels = safeIncludedLabels.every(requiredLabel => + const itemLabels = item.labels?.map((l) => l.name) || []; + const hasAllIncludedLabels = safeIncludedLabels.every((requiredLabel) => itemLabels.includes(requiredLabel) ); if (!hasAllIncludedLabels) { @@ -172,7 +305,7 @@ export const filterByLabels = ( // Apply exclusive label filters - item must have NONE of the excluded labels if ( safeExcludedLabels.length > 0 && - item.labels?.some(l => safeExcludedLabels.includes(l.name)) + item.labels?.some((l) => safeExcludedLabels.includes(l.name)) ) { return false; } @@ -183,10 +316,6 @@ export const filterByLabels = ( /** * Filters GitHub items based on repository - * - * @param items - Array of GitHub items to filter - * @param repoFilters - Array of repository names in 'owner/repo' format - * @returns Filtered array of items */ export const filterByRepository = ( items: GitHubItem[], @@ -195,180 +324,127 @@ export const filterByRepository = ( const safeRepoFilters = repoFilters || []; if (safeRepoFilters.length === 0) return items; - return items.filter(item => { - const itemRepo = item.repository_url?.replace( - 'https://api.github.com/repos/', - '' - ); - const included = itemRepo && safeRepoFilters.includes(itemRepo); - return included; + return items.filter((item) => { + const itemRepo = getRepositoryName(item); + return itemRepo && safeRepoFilters.includes(itemRepo); }); }; /** * Filters GitHub items based on user - * - * @param items - Array of GitHub items to filter - * @param userFilter - Username to filter by (case-insensitive) - * @returns Filtered array of items */ -export const filterByUser = ( - items: GitHubItem[], - userFilter: string | undefined -): GitHubItem[] => { +export const filterByUser = (items: GitHubItem[], userFilter: string | undefined): GitHubItem[] => { if (!userFilter?.trim()) return items; - return items.filter(item => { + return items.filter((item) => { return item.user.login.toLowerCase() === userFilter.toLowerCase(); }); }; /** - * Parses search text to extract label filters, user filters, repo filters, and regular text - * - * @param searchText - The search text to parse - * @returns Object containing includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, and cleanText + * Filters GitHub items based on text search in title and body, with support for advanced syntax */ -export const parseSearchText = (searchText: string): { - includedLabels: string[]; - excludedLabels: string[]; - userFilters: string[]; - includedRepos: string[]; - excludedRepos: string[]; - cleanText: string; -} => { - if (!searchText.trim()) { - return { includedLabels: [], excludedLabels: [], userFilters: [], includedRepos: [], excludedRepos: [], cleanText: '' }; - } - - // Check cache first - const cached = parseSearchTextCache.get(searchText); - if (cached) { - return cached; - } - - const includedLabels: string[] = []; - const excludedLabels: string[] = []; - const userFilters: string[] = []; - const includedRepos: string[] = []; - const excludedRepos: string[] = []; - let cleanText = searchText; - - // First, find all -label:{labelname} patterns (excluded labels) - const excludeLabelRegex = /-label:([^\s]+)/g; - let match; - const excludeMatches: RegExpExecArray[] = []; - while ((match = excludeLabelRegex.exec(searchText)) !== null) { - excludedLabels.push(match[1]); - excludeMatches.push(match); - } - - // Remove all excluded label matches from cleanText - excludeMatches.forEach(m => { - cleanText = cleanText.replace(m[0], ' '); - }); - - // Then find all label:{labelname} patterns (included labels) from the cleaned text - const includeLabelRegex = /\blabel:([^\s]+)/g; - const includeMatches: RegExpExecArray[] = []; - while ((match = includeLabelRegex.exec(cleanText)) !== null) { - includedLabels.push(match[1]); - includeMatches.push(match); - } - - // Remove all included label matches from cleanText - includeMatches.forEach(m => { - cleanText = cleanText.replace(m[0], ' '); - }); +export const filterByText = (items: GitHubItem[], searchText: string): GitHubItem[] => { + if (!searchText.trim()) return items; - // Then find all user:{username} patterns from the cleaned text - const userRegex = /\buser:([^\s]+)/g; - const userMatches: RegExpExecArray[] = []; - while ((match = userRegex.exec(cleanText)) !== null) { - userFilters.push(match[1]); - userMatches.push(match); - } + const { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText } = + parseSearchText(searchText); - // Remove all user matches from cleanText - userMatches.forEach(m => { - cleanText = cleanText.replace(m[0], ' '); - }); + return items.filter((item) => { + // Check label filters first + if (includedLabels.length > 0 || excludedLabels.length > 0) { + const itemLabels = (item.labels || []).map((label) => label.name.toLowerCase()); - // Then find all -repo:{reponame} patterns (excluded repos) - const excludeRepoRegex = /-repo:([^\s]+)/g; - const excludeRepoMatches: RegExpExecArray[] = []; - while ((match = excludeRepoRegex.exec(cleanText)) !== null) { - excludedRepos.push(match[1]); - excludeRepoMatches.push(match); - } + if (includedLabels.length > 0) { + const hasAllIncludedLabels = includedLabels.every((labelName) => + itemLabels.includes(labelName.toLowerCase()) + ); + if (!hasAllIncludedLabels) return false; + } - // Remove all excluded repo matches from cleanText - excludeRepoMatches.forEach(m => { - cleanText = cleanText.replace(m[0], ' '); - }); + if (excludedLabels.length > 0) { + const hasExcludedLabel = excludedLabels.some((labelName) => + itemLabels.includes(labelName.toLowerCase()) + ); + if (hasExcludedLabel) return false; + } + } - // Then find all repo:{reponame} patterns from the cleaned text - const includeRepoRegex = /\brepo:([^\s]+)/g; - const includeRepoMatches: RegExpExecArray[] = []; - while ((match = includeRepoRegex.exec(cleanText)) !== null) { - includedRepos.push(match[1]); - includeRepoMatches.push(match); - } + // Check user filters + if (userFilters.length > 0) { + const itemUser = item.user.login.toLowerCase(); + const matchesUser = userFilters.some( + (userFilter) => itemUser === userFilter.toLowerCase() + ); + if (!matchesUser) return false; + } - // Remove all included repo matches from cleanText - includeRepoMatches.forEach(m => { - cleanText = cleanText.replace(m[0], ' '); - }); + // Check repository filters + if (includedRepos.length > 0 || excludedRepos.length > 0) { + const itemRepo = getRepositoryName(item); - // Clean up extra whitespace - cleanText = cleanText.replace(/\s+/g, ' ').trim(); + if (!itemRepo) { + if (includedRepos.length > 0) return false; + } else { + if (includedRepos.length > 0) { + const hasIncludedRepo = includedRepos.some( + (repoFilter) => itemRepo.toLowerCase() === repoFilter.toLowerCase() + ); + if (!hasIncludedRepo) return false; + } - const result = { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText }; + if (excludedRepos.length > 0) { + const hasExcludedRepo = excludedRepos.some( + (repoFilter) => itemRepo.toLowerCase() === repoFilter.toLowerCase() + ); + if (hasExcludedRepo) return false; + } + } + } - // Cache the result - if (parseSearchTextCache.size >= MAX_CACHE_SIZE) { - // Remove oldest entry (first in Map) - const firstKey = parseSearchTextCache.keys().next().value; - if (firstKey !== undefined) { - parseSearchTextCache.delete(firstKey); + // If there's clean text remaining, search in title and body + if (cleanText) { + const searchLower = cleanText.toLowerCase(); + const titleMatch = item.title.toLowerCase().includes(searchLower); + const bodyMatch = item.body?.toLowerCase().includes(searchLower); + return titleMatch || bodyMatch; } - } - parseSearchTextCache.set(searchText, result); - return result; + // If only label/user/repo filters were used, item passed checks above + return true; + }); }; +// ============================================================================ +// ADVANCED SEARCH FILTERING (for views) +// ============================================================================ + /** - * Filters GitHub items based on text search in title and body, with support for label, user, and repo syntax - * - * @param items - Array of GitHub items to filter - * @param searchText - Text to search for, supporting label:{name}, -label:{name}, user:{username}, repo:{owner/repo}, and -repo:{owner/repo} syntax - * @returns Filtered array of items + * Advanced filtering logic for GitHub items based on labels, users, and text + * This is the main filtering function used by Summary.tsx and EventView.tsx */ -export const filterByText = ( - items: GitHubItem[], - searchText: string -): GitHubItem[] => { - if (!searchText.trim()) return items; +export const filterItemsByAdvancedSearch = (items: GitHubItem[], searchText: string): GitHubItem[] => { + if (!searchText || !searchText.trim()) { + return items; + } - const { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText } = parseSearchText(searchText); + const { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText } = + parseSearchText(searchText); - return items.filter(item => { + return items.filter((item) => { // Check label filters first if (includedLabels.length > 0 || excludedLabels.length > 0) { - const itemLabels = (item.labels || []).map(label => label.name.toLowerCase()); - - // Check if item has all required included labels + const itemLabels = (item.labels || []).map((label) => label.name.toLowerCase()); + if (includedLabels.length > 0) { - const hasAllIncludedLabels = includedLabels.every(labelName => + const hasAllIncludedLabels = includedLabels.every((labelName) => itemLabels.includes(labelName.toLowerCase()) ); if (!hasAllIncludedLabels) return false; } - - // Check if item has any excluded labels + if (excludedLabels.length > 0) { - const hasExcludedLabel = excludedLabels.some(labelName => + const hasExcludedLabel = excludedLabels.some((labelName) => itemLabels.includes(labelName.toLowerCase()) ); if (hasExcludedLabel) return false; @@ -378,81 +454,76 @@ export const filterByText = ( // Check user filters if (userFilters.length > 0) { const itemUser = item.user.login.toLowerCase(); - const matchesUser = userFilters.some(userFilter => - itemUser === userFilter.toLowerCase() + const matchesUser = userFilters.some( + (userFilter) => itemUser === userFilter.toLowerCase() ); if (!matchesUser) return false; } // Check repository filters if (includedRepos.length > 0 || excludedRepos.length > 0) { - // Extract repository name from repository_url (format: https://api.github.com/repos/owner/repo) - const itemRepo = item.repository_url?.replace('https://api.github.com/repos/', ''); - + const itemRepo = getRepositoryName(item); + if (!itemRepo) { - // If no repository info, exclude if any repo filters are specified if (includedRepos.length > 0) return false; } else { - // Check if item has all required included repos if (includedRepos.length > 0) { - const hasIncludedRepo = includedRepos.some(repoFilter => - itemRepo.toLowerCase() === repoFilter.toLowerCase() + const hasIncludedRepo = includedRepos.some( + (repoFilter) => itemRepo.toLowerCase() === repoFilter.toLowerCase() ); if (!hasIncludedRepo) return false; } - // Check if item has any excluded repos if (excludedRepos.length > 0) { - const hasExcludedRepo = excludedRepos.some(repoFilter => - itemRepo.toLowerCase() === repoFilter.toLowerCase() + const hasExcludedRepo = excludedRepos.some( + (repoFilter) => itemRepo.toLowerCase() === repoFilter.toLowerCase() ); if (hasExcludedRepo) return false; } } } - // If there's clean text remaining, search in title and body + // If there's clean text remaining, search in title, body, and username if (cleanText) { const searchLower = cleanText.toLowerCase(); const titleMatch = item.title.toLowerCase().includes(searchLower); const bodyMatch = item.body?.toLowerCase().includes(searchLower); - return titleMatch || bodyMatch; + const userMatch = item.user.login.toLowerCase().includes(searchLower); + return titleMatch || bodyMatch || userMatch; } - // If only label/user/repo filters were used, item passed checks above return true; }); }; +// ============================================================================ +// SORTING +// ============================================================================ + /** - * Sorts GitHub items by date - * - * @param items - Array of GitHub items to sort - * @param sortOrder - Sort order ('updated' or 'created') - * @returns Sorted array of items (newest first) + * Sorts GitHub items by date (newest first) */ -export const sortItems = ( - items: GitHubItem[], - sortOrder: 'updated' | 'created' -): GitHubItem[] => { +export const sortItems = (items: GitHubItem[], sortOrder: 'updated' | 'created'): GitHubItem[] => { return [...items].sort((a, b) => { - const dateA = new Date( - sortOrder === 'updated' ? a.updated_at : a.created_at - ); - const dateB = new Date( - sortOrder === 'updated' ? b.updated_at : b.created_at - ); + const dateA = new Date(sortOrder === 'updated' ? a.updated_at : a.created_at); + const dateB = new Date(sortOrder === 'updated' ? b.updated_at : b.created_at); return dateB.getTime() - dateA.getTime(); }); }; +/** + * Sorts GitHub items by updated date (newest first) + */ +export const sortItemsByUpdatedDate = (items: GitHubItem[]): GitHubItem[] => { + return sortItems(items, 'updated'); +}; + +// ============================================================================ +// COMBINED FILTERS +// ============================================================================ + /** * Applies all filters and sorting to GitHub items - * - * @param items - Array of GitHub items to process - * @param filters - Filter configuration object - * @param sortOrder - Sort order ('updated' or 'created') - * @returns Filtered and sorted array of items */ export const applyFiltersAndSort = ( items: GitHubItem[], @@ -468,11 +539,7 @@ export const applyFiltersAndSort = ( // Apply all filters in sequence filteredItems = filterByType(filteredItems, filters.filter); filteredItems = filterByStatus(filteredItems, filters.statusFilter); - filteredItems = filterByLabels( - filteredItems, - filters.includedLabels, - filters.excludedLabels - ); + filteredItems = filterByLabels(filteredItems, filters.includedLabels, filters.excludedLabels); filteredItems = filterByRepository(filteredItems, filters.repoFilters); filteredItems = filterByUser(filteredItems, filters.userFilter); filteredItems = filterByText(filteredItems, filters.searchText); @@ -483,31 +550,97 @@ export const applyFiltersAndSort = ( return filteredItems; }; +// ============================================================================ +// COUNTING & UTILITIES +// ============================================================================ + /** - * Checks if a GitHub item is merged + * Counts the number of GitHub items that match the specified filter criteria. + * + * @example + * // Count all pull requests + * const prCount = countItemsMatchingFilter(items, 'type', 'pr', []); * - * @param item - GitHub item to check - * @returns True if the item is a merged pull request + * // Count open issues + * const openCount = countItemsMatchingFilter(items, 'status', 'open', []); */ -export const isMerged = (item: GitHubItem): boolean => { - return !!(item.pull_request && (item.pull_request.merged_at || item.merged)); +export const countItemsMatchingFilter = ( + items: GitHubItem[], + filterType: FilterType, + filterValue: FilterValue, + excludedLabels: string[] +): number => { + if (!Array.isArray(items)) { + return 0; + } + + switch (filterType) { + case 'type': + return items.filter((item) => { + if (filterValue === 'all') return true; + + const isComment = item.title.startsWith('Comment on:'); + + if (filterValue === 'pr') { + return !!item.pull_request && !isComment; + } + if (filterValue === 'issue') { + return !item.pull_request && !isComment; + } + if (filterValue === 'comment') { + return isComment; + } + return false; + }).length; + + case 'status': + if (filterValue === 'merged') { + return items.filter((item) => isMerged(item)).length; + } + return items.filter((item) => { + if (filterValue === 'all') return true; + if (isMerged(item)) return false; + return item.state === filterValue; + }).length; + + case 'label': + return items.filter((item) => { + const hasLabel = item.labels?.some((l) => l.name === filterValue); + if (!hasLabel) return false; + const hasExcludedLabel = item.labels?.some((l) => excludedLabels.includes(l.name)); + return !hasExcludedLabel; + }).length; + + case 'repo': + return items.filter((item) => { + const itemRepo = getRepositoryName(item); + return itemRepo === filterValue; + }).length; + + default: + return 0; + } }; /** - * Gets the repository name from a GitHub item - * - * @param item - GitHub item - * @returns Repository name in 'owner/repo' format, or undefined if not available + * Extracts unique labels from an array of GitHub items */ -export const getRepositoryName = (item: GitHubItem): string | undefined => { - return item.repository_url?.replace('https://api.github.com/repos/', ''); +export const extractAvailableLabels = (items: GitHubItem[]): string[] => { + const labels = new Set(); + + if (!Array.isArray(items)) { + return []; + } + + items.forEach((item) => { + item.labels?.forEach((label) => labels.add(label.name)); + }); + + return Array.from(labels).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); }; /** * Checks if any filters are active (not in default state) - * - * @param filters - Filter configuration object - * @returns True if any filters are active */ export const hasActiveFilters = (filters: ResultsFilter): boolean => { return ( @@ -523,8 +656,6 @@ export const hasActiveFilters = (filters: ResultsFilter): boolean => { /** * Creates a default filter configuration - * - * @returns Default ResultsFilter object */ export const createDefaultFilter = (): ResultsFilter => ({ filter: 'all', @@ -538,9 +669,6 @@ export const createDefaultFilter = (): ResultsFilter => ({ /** * Generates a human-readable summary of active filters - * - * @param filters - Filter configuration object - * @returns Array of filter summary strings */ export const getFilterSummary = (filters: ResultsFilter): string[] => { const summaryParts: string[] = []; @@ -569,3 +697,26 @@ export const getFilterSummary = (filters: ResultsFilter): string[] => { return summaryParts; }; + +// ============================================================================ +// USERNAME HELPERS +// ============================================================================ + +/** + * Parses comma-separated usernames + */ +export const parseCommaSeparatedUsernames = (username: string): string[] => { + return username.split(',').map((u) => u.trim().toLowerCase()); +}; + +/** + * Checks if an item is authored by any of the searched users + */ +export const isItemAuthoredBySearchedUsers = ( + item: GitHubItem, + searchedUsernames: string[] +): boolean => { + const itemAuthor = item.user.login.toLowerCase(); + const searchedUsernamesLower = searchedUsernames.map((username) => username.toLowerCase()); + return searchedUsernamesLower.includes(itemAuthor); +}; diff --git a/src/utils/rawDataUtils.ts b/src/utils/githubData.ts similarity index 66% rename from src/utils/rawDataUtils.ts rename to src/utils/githubData.ts index b79588d..97e28fa 100644 --- a/src/utils/rawDataUtils.ts +++ b/src/utils/githubData.ts @@ -1,12 +1,18 @@ -import { GitHubItem, GitHubEvent } from '../types'; - /** - * Raw Data Utilities - * - * Provides functions for categorizing and processing raw data in the UI - * instead of on the backend. + * GitHub Data Utilities + * + * Consolidates all GitHub data transformation and enrichment: + * - Event to GitHubItem transformation + * - Raw data processing and categorization + * - PR detail enrichment */ +import { GitHubItem, GitHubEvent } from '../types'; + +// ============================================================================ +// EVENT TRANSFORMATION +// ============================================================================ + /** * Transforms GitHub Event to GitHubItem * @@ -31,7 +37,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { event_id: event.id, html_url: issue.html_url, title: issue.title, - created_at: event.created_at, // Use event timestamp, not issue timestamp + created_at: event.created_at, updated_at: issue.updated_at, state: issue.state, body: issue.body, @@ -43,32 +49,28 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { }, closed_at: issue.closed_at, number: issue.number, - user: actorUser, // Use event actor instead of issue user - assignee: (payload as any).issue?.assignee || null, // Extract assignee from original payload - assignees: (payload as any).issue?.assignees || [], + user: actorUser, + assignee: (payload as Record).issue?.assignee || null, + assignees: (payload as Record).issue?.assignees || [], pull_request: issue.pull_request, original: payload, originalEventType: type, }; } else if (type === 'PullRequestEvent' && payload.pull_request) { const pr = payload.pull_request; - const payloadWithAction = payload as { action?: string; number?: number; labels?: any[] }; - - // GitHub API changed format - pr object no longer contains full details - // Construct what we can from available data + const payloadWithAction = payload as { action?: string; number?: number; labels?: { name: string; color?: string; description?: string }[] }; + const prNumber = pr.number || payloadWithAction.number; const htmlUrl = pr.html_url || `https://github.com/${repo.name}/pull/${prNumber}`; const action = payloadWithAction.action || 'updated'; - - // Create a descriptive title based on the action since title is not provided const title = pr.title || `Pull Request #${prNumber} ${action}`; - + return { id: pr.id, event_id: event.id, html_url: htmlUrl, title: title, - created_at: event.created_at, // Use event timestamp, not PR timestamp + created_at: event.created_at, updated_at: pr.updated_at || event.created_at, state: pr.state || 'open', body: pr.body || `Pull request ${action} by ${actorUser.login}`, @@ -82,7 +84,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { merged_at: pr.merged_at, merged: pr.merged, number: prNumber, - user: actorUser, // Use event actor instead of PR user + user: actorUser, pull_request: { merged_at: pr.merged_at, url: htmlUrl, @@ -93,18 +95,17 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { } else if (type === 'PullRequestReviewEvent' && payload.pull_request) { const pr = payload.pull_request; const payloadWithAction = payload as { action?: string; number?: number }; - - // GitHub API changed format - pr object no longer contains full details + const prNumber = pr.number || payloadWithAction.number; const htmlUrl = pr.html_url || `https://github.com/${repo.name}/pull/${prNumber}`; const prTitle = pr.title || `Pull Request #${prNumber}`; - + return { id: pr.id, event_id: event.id, html_url: htmlUrl, title: `Review on: ${prTitle}`, - created_at: event.created_at, // Use event timestamp, not PR timestamp + created_at: event.created_at, updated_at: pr.updated_at || event.created_at, state: pr.state || 'open', body: pr.body || `Review by ${actorUser.login}`, @@ -118,7 +119,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { merged_at: pr.merged_at, merged: pr.merged, number: prNumber, - user: actorUser, // Use event actor instead of PR user + user: actorUser, pull_request: { merged_at: pr.merged_at, url: htmlUrl, @@ -134,7 +135,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { event_id: event.id, html_url: comment.html_url, title: `Comment on: ${issue.title}`, - created_at: event.created_at, // Use event timestamp, not comment timestamp + created_at: event.created_at, updated_at: comment.updated_at, state: issue.state, body: comment.body, @@ -146,7 +147,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { }, closed_at: issue.closed_at, number: issue.number, - user: actorUser, // Use event actor instead of comment user + user: actorUser, pull_request: issue.pull_request, original: payload, originalEventType: type, @@ -155,18 +156,17 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { const comment = payload.comment; const pr = payload.pull_request; const payloadWithAction = payload as { action?: string; number?: number }; - - // GitHub API changed format - pr object no longer contains full details + const prNumber = pr.number || payloadWithAction.number; const prHtmlUrl = pr.html_url || `https://github.com/${repo.name}/pull/${prNumber}`; const prTitle = pr.title || `Pull Request #${prNumber}`; - + return { id: comment.id, event_id: event.id, html_url: comment.html_url, title: `Review comment on: ${prTitle}`, - created_at: event.created_at, // Use event timestamp, not comment timestamp + created_at: event.created_at, updated_at: comment.updated_at, state: pr.state || 'open', body: comment.body, @@ -180,7 +180,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { merged_at: pr.merged_at, merged: pr.merged, number: prNumber, - user: actorUser, // Use event actor instead of comment user + user: actorUser, pull_request: { merged_at: pr.merged_at, url: prHtmlUrl, @@ -189,41 +189,42 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'PushEvent') { - // Handle PushEvent - create a GitHubItem from push event data - const pushPayload = payload as { ref?: string; commits?: Array<{ message: string }>; distinct_size?: number }; + const pushPayload = payload as { + ref?: string; + commits?: Array<{ message: string }>; + distinct_size?: number; + }; const branch = pushPayload?.ref?.replace('refs/heads/', '') || 'main'; const commitCount = pushPayload?.commits?.length || 0; const distinctCount = pushPayload?.distinct_size || 0; - - // Create a title that describes the push + let title = `Pushed ${distinctCount} commit${distinctCount !== 1 ? 's' : ''} to ${branch}`; if (commitCount > distinctCount) { title += ` (${commitCount} total)`; } - - // Create a body with commit messages if available + let body = ''; if (pushPayload?.commits && pushPayload.commits.length > 0) { body = pushPayload.commits - .slice(0, 5) // Show first 5 commits - .map((commit) => `- ${commit.message ? commit.message.split('\n')[0] : 'No commit message'}`) // First line of commit message + .slice(0, 5) + .map((commit) => `- ${commit.message ? commit.message.split('\n')[0] : 'No commit message'}`) .join('\n'); - + if (pushPayload.commits.length > 5) { body += `\n... and ${pushPayload.commits.length - 5} more commits`; } } - + return { - id: parseInt(event.id), // Convert string ID to number for GitHubItem + id: parseInt(event.id), event_id: event.id, html_url: `https://github.com/${repo.name}/commits/${branch}`, title: title, created_at: event.created_at, - updated_at: event.created_at, // Push events don't have updated_at, use created_at - state: 'open', // Push events are always "open" + updated_at: event.created_at, + state: 'open', body: body, - labels: [], // Push events don't have labels + labels: [], repository_url: `https://api.github.com/repos/${repo.name}`, repository: { full_name: repo.name, @@ -232,17 +233,20 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { user: actorUser, original: payload, originalEventType: type, - // Push events don't have pull_request, closed_at, merged_at, or number }; } else if (type === 'CreateEvent') { - // Handle CreateEvent - create a GitHubItem from create event data - const createPayload = payload as { ref_type?: string; ref?: string; master_branch?: string; description?: string }; + const createPayload = payload as { + ref_type?: string; + ref?: string; + master_branch?: string; + description?: string; + }; const refType = createPayload?.ref_type || 'repository'; const ref = createPayload?.ref || ''; - + let title = ''; let htmlUrl = `https://github.com/${repo.name}`; - + if (refType === 'branch') { title = `Created branch ${ref}`; htmlUrl = `https://github.com/${repo.name}/tree/${ref}`; @@ -255,7 +259,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { title += `: ${createPayload.description}`; } } - + return { id: parseInt(event.id), event_id: event.id, @@ -276,11 +280,10 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'ForkEvent') { - // Handle ForkEvent - create a GitHubItem from fork event data const forkPayload = payload as { forkee?: { full_name?: string; html_url?: string } }; const forkeeName = forkPayload?.forkee?.full_name || 'unknown repository'; const forkeeUrl = forkPayload?.forkee?.html_url || `https://github.com/${repo.name}`; - + return { id: parseInt(event.id), event_id: event.id, @@ -301,9 +304,8 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'WatchEvent') { - // Handle WatchEvent - create a GitHubItem from watch event data const action = payload?.action || 'starred'; - + return { id: parseInt(event.id), event_id: event.id, @@ -324,7 +326,6 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'PublicEvent') { - // Handle PublicEvent - create a GitHubItem from public event data return { id: parseInt(event.id), event_id: event.id, @@ -345,11 +346,10 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'DeleteEvent') { - // Handle DeleteEvent - create a GitHubItem from delete event data const deletePayload = payload as { ref_type?: string; ref?: string }; const refType = deletePayload?.ref_type || 'branch'; const ref = deletePayload?.ref || ''; - + let title = ''; if (refType === 'branch') { title = `Deleted branch ${ref}`; @@ -358,7 +358,7 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { } else { title = `Deleted ${refType}`; } - + return { id: parseInt(event.id), event_id: event.id, @@ -379,37 +379,38 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { originalEventType: type, }; } else if (type === 'GollumEvent') { - // Handle GollumEvent - create a GitHubItem from gollum event data - const gollumPayload = payload as { pages?: Array<{ page_name: string; title: string; action: string; html_url: string }> }; + const gollumPayload = payload as { + pages?: Array<{ page_name: string; title: string; action: string; html_url: string }>; + }; const pages = gollumPayload?.pages || []; - + if (pages.length === 0) { - return null; // No pages to display + return null; } - - const page = pages[0]; // Show the first page + + const page = pages[0]; const action = page.action || 'updated'; const pageCount = pages.length; - + let title = ''; if (pageCount === 1) { title = `${action === 'created' ? 'Created' : action === 'edited' ? 'Updated' : 'Deleted'} wiki page: ${page.title}`; } else { title = `${action === 'created' ? 'Created' : action === 'edited' ? 'Updated' : 'Deleted'} ${pageCount} wiki pages`; } - + let body = ''; if (pages.length > 0) { body = pages .slice(0, 5) .map((p) => `- ${p.title} (${p.action})`) .join('\n'); - + if (pages.length > 5) { body += `\n... and ${pages.length - 5} more pages`; } } - + return { id: parseInt(event.id), event_id: event.id, @@ -431,17 +432,15 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { }; } - // Return null for events that don't contain relevant data return null; }; +// ============================================================================ +// RAW DATA PROCESSING +// ============================================================================ + /** * Categorizes raw GitHub events into processed items - * - * @param rawEvents - Array of raw GitHub events - * @param startDate - Start date for filtering (YYYY-MM-DD) - * @param endDate - End date for filtering (YYYY-MM-DD) - * @returns Array of processed GitHub items */ export const processRawEvents = ( rawEvents: GitHubEvent[], @@ -449,23 +448,20 @@ export const processRawEvents = ( endDate?: string ): GitHubItem[] => { const items: GitHubItem[] = []; - - // Set up date filtering if dates are provided + const startDateTime = startDate ? new Date(startDate).getTime() : 0; const endDateTime = endDate ? new Date(endDate).getTime() + 24 * 60 * 60 * 1000 : Infinity; for (const event of rawEvents) { const eventTime = new Date(event.created_at).getTime(); - // Filter by date range if dates are provided if (startDate && eventTime < startDateTime) { - continue; // Skip events before start date + continue; } if (endDate && eventTime > endDateTime) { - continue; // Skip events after end date + continue; } - // Transform event to item const item = transformEventToItem(event); if (item) { items.push(item); @@ -478,47 +474,39 @@ export const processRawEvents = ( /** * Categorizes raw search API results (already in GitHubItem format) * This is mainly for consistency, date filtering, and deduplication - * - * @param rawItems - Array of raw GitHub items from search API - * @param startDate - Start date for filtering (YYYY-MM-DD) - * @param endDate - End date for filtering (YYYY-MM-DD) - * @returns Array of filtered and deduplicated GitHub items */ export const categorizeRawSearchItems = ( rawItems: GitHubItem[], startDate?: string, endDate?: string ): GitHubItem[] => { - // Set up date filtering if dates are provided const startDateTime = startDate ? new Date(startDate).getTime() : 0; const endDateTime = endDate ? new Date(endDate).getTime() + 24 * 60 * 60 * 1000 : Infinity; // First filter by date and validate required fields - const dateFilteredItems = rawItems.filter(item => { - // Validate required fields - skip items with missing title + const dateFilteredItems = rawItems.filter((item) => { if (!item.title) { console.warn('Skipping item with missing title:', item.html_url || item.id); return false; } - + const itemTime = new Date(item.updated_at).getTime(); - // Filter by date range if dates are provided if (startDate && itemTime < startDateTime) { - return false; // Skip items before start date + return false; } if (endDate && itemTime > endDateTime) { - return false; // Skip items after end date + return false; } return true; }); - // Then remove duplicates based on html_url (unique identifier for issues/PRs) + // Then remove duplicates based on html_url const urlSet = new Set(); const deduplicatedItems: GitHubItem[] = []; - - dateFilteredItems.forEach(item => { + + dateFilteredItems.forEach((item) => { if (!urlSet.has(item.html_url)) { urlSet.add(item.html_url); deduplicatedItems.push(item); @@ -530,9 +518,6 @@ export const categorizeRawSearchItems = ( /** * Gets all available labels from raw events - * - * @param rawEvents - Array of raw GitHub events - * @returns Array of unique label names */ export const getAvailableLabelsFromRawEvents = (rawEvents: GitHubEvent[]): string[] => { const labelSet = new Set(); @@ -551,9 +536,6 @@ export const getAvailableLabelsFromRawEvents = (rawEvents: GitHubEvent[]): strin /** * Gets all available repositories from raw events - * - * @param rawEvents - Array of raw GitHub events - * @returns Array of unique repository names */ export const getAvailableReposFromRawEvents = (rawEvents: GitHubEvent[]): string[] => { const repoSet = new Set(); @@ -570,9 +552,6 @@ export const getAvailableReposFromRawEvents = (rawEvents: GitHubEvent[]): string /** * Gets all available users from raw events - * - * @param rawEvents - Array of raw GitHub events - * @returns Array of unique usernames */ export const getAvailableUsersFromRawEvents = (rawEvents: GitHubEvent[]): string[] => { const userSet = new Set(); @@ -585,4 +564,223 @@ export const getAvailableUsersFromRawEvents = (rawEvents: GitHubEvent[]): string } return Array.from(userSet).sort(); -}; \ No newline at end of file +}; + +// ============================================================================ +// PR ENRICHMENT +// ============================================================================ + +interface PRDetails { + title: string; + state: string; + body: string; + html_url: string; + labels: Array<{ name: string; color?: string; description?: string }>; + updated_at: string; + closed_at?: string; + merged_at?: string; + merged?: boolean; +} + +// In-memory cache for PR details to avoid duplicate fetches +const prCache = new Map(); + +/** + * Extracts PR API URL from a GitHubItem + * Returns null if the item is not a PR or doesn't have a PR URL + */ +const getPRApiUrl = (item: GitHubItem): string | null => { + if (!item.originalEventType?.includes('PullRequest')) { + return null; + } + + const match = item.html_url?.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); + if (!match) { + return null; + } + + const [, repoFullName, prNumber] = match; + return `https://api.github.com/repos/${repoFullName}/pulls/${prNumber}`; +}; + +/** + * Checks if an item needs PR details enrichment + */ +export const needsPREnrichment = (item: GitHubItem): boolean => { + if (!item.originalEventType?.includes('PullRequest')) { + return false; + } + + // Check if title is a generic fallback (indicates missing data) + if ( + item.title?.match( + /^Pull Request #\d+ (opened|closed|labeled|unlabeled|synchronized|reopened|edited|assigned|unassigned|review_requested|review_request_removed)$/ + ) + ) { + return true; + } + + // Check if title starts with "Review on: Pull Request #" (indicates missing PR title) + if (item.title?.startsWith('Review on: Pull Request #')) { + return true; + } + + // Check if title starts with "Review comment on: Pull Request #" (indicates missing PR title) + if (item.title?.startsWith('Review comment on: Pull Request #')) { + return true; + } + + return false; +}; + +/** + * Fetches PR details from GitHub API + */ +const fetchPRDetails = async (apiUrl: string, githubToken?: string): Promise => { + if (prCache.has(apiUrl)) { + return prCache.get(apiUrl)!; + } + + try { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + }; + + if (githubToken) { + headers['Authorization'] = `token ${githubToken}`; + } + + const response = await fetch(apiUrl, { headers }); + + if (!response.ok) { + console.warn(`Failed to fetch PR details from ${apiUrl}: ${response.status}`); + return null; + } + + const data = await response.json(); + + const details: PRDetails = { + title: data.title, + state: data.state, + body: data.body || '', + html_url: data.html_url, + labels: data.labels || [], + updated_at: data.updated_at, + closed_at: data.closed_at, + merged_at: data.merged_at, + merged: data.merged, + }; + + prCache.set(apiUrl, details); + + return details; + } catch (error) { + console.warn(`Error fetching PR details from ${apiUrl}:`, error); + return null; + } +}; + +/** + * Enriches a single GitHubItem with full PR details if needed + */ +export const enrichItemWithPRDetails = async ( + item: GitHubItem, + githubToken?: string +): Promise => { + if (!githubToken) { + return item; + } + + if (!needsPREnrichment(item)) { + return item; + } + + const apiUrl = getPRApiUrl(item); + if (!apiUrl) { + return item; + } + + const prDetails = await fetchPRDetails(apiUrl, githubToken); + if (!prDetails) { + return item; + } + + const enrichedItem: GitHubItem = { + ...item, + labels: prDetails.labels.length > 0 ? prDetails.labels : item.labels, + updated_at: prDetails.updated_at || item.updated_at, + closed_at: prDetails.closed_at || item.closed_at, + merged_at: prDetails.merged_at || item.merged_at, + merged: prDetails.merged !== undefined ? prDetails.merged : item.merged, + state: prDetails.state || item.state, + }; + + // Update title based on event type + if (item.originalEventType === 'PullRequestReviewEvent') { + enrichedItem.title = `Review on: ${prDetails.title}`; + } else if (item.originalEventType === 'PullRequestReviewCommentEvent') { + enrichedItem.title = `Review comment on: ${prDetails.title}`; + } else if (item.title?.match(/^Pull Request #\d+/)) { + const actionMatch = item.title?.match(/^Pull Request #\d+ (.+)$/); + const action = actionMatch ? actionMatch[1] : ''; + enrichedItem.title = action ? `${prDetails.title} (${action})` : prDetails.title; + } + + return enrichedItem; +}; + +/** + * Enriches multiple GitHubItems with PR details in batch + * Only fetches details for items that need enrichment + */ +export const enrichItemsWithPRDetails = async ( + items: GitHubItem[], + githubToken?: string, + onProgress?: (current: number, total: number) => void +): Promise => { + if (!githubToken) { + return items; + } + + const itemsNeedingEnrichment = items.filter(needsPREnrichment); + + if (itemsNeedingEnrichment.length === 0) { + return items; + } + + console.log(`Enriching ${itemsNeedingEnrichment.length} items with PR details...`); + + const enrichmentMap = new Map(); + + let processed = 0; + for (const item of itemsNeedingEnrichment) { + const enrichedItem = await enrichItemWithPRDetails(item, githubToken); + enrichmentMap.set(item.id, enrichedItem); + + processed++; + if (onProgress) { + onProgress(processed, itemsNeedingEnrichment.length); + } + + // Add a small delay to respect rate limits + if (processed < itemsNeedingEnrichment.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return items.map((item) => enrichmentMap.get(item.id) || item); +}; + +/** + * Clears the PR details cache + */ +export const clearPRCache = (): void => { + prCache.clear(); +}; + +/** + * Gets the current cache size (for debugging/monitoring) + */ +export const getPRCacheSize = (): number => { + return prCache.size; +}; diff --git a/src/utils/githubSearch.test.ts b/src/utils/githubSearch.test.ts index 211aac4..455d89a 100644 --- a/src/utils/githubSearch.test.ts +++ b/src/utils/githubSearch.test.ts @@ -20,7 +20,7 @@ vi.mock('../utils', () => ({ updateUrlParams: vi.fn(), })); -vi.mock('./usernameCache', () => ({ +vi.mock('./storage', () => ({ categorizeUsernames: vi.fn(), getInvalidUsernames: vi.fn(), })); @@ -36,7 +36,7 @@ import { validateUsernameList, updateUrlParams, } from '../utils'; -import { categorizeUsernames, getInvalidUsernames } from './usernameCache'; +import { categorizeUsernames, getInvalidUsernames } from './storage'; describe('githubSearch utilities', () => { const mockCache: UsernameCache = { diff --git a/src/utils/githubSearch.ts b/src/utils/githubSearch.ts index 6c7ad2b..ca6ec1d 100644 --- a/src/utils/githubSearch.ts +++ b/src/utils/githubSearch.ts @@ -5,7 +5,7 @@ import { validateUsernameList, type BatchValidationResult, } from '../utils'; -import { categorizeUsernames, getInvalidUsernames } from './usernameCache'; +import { categorizeUsernames, getInvalidUsernames } from './storage'; import type { UsernameCache } from '../types'; /** diff --git a/src/utils/indexedDB.ts b/src/utils/indexedDB.ts deleted file mode 100644 index e752e70..0000000 --- a/src/utils/indexedDB.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { GitHubEvent } from '../types'; - -/** - * IndexedDB utilities for storing and retrieving GitHub events data - * Provides much more storage capacity than localStorage - */ - -const DB_NAME = 'GitVegasDB'; -const DB_VERSION = 1; -const EVENTS_STORE = 'events'; -const METADATA_STORE = 'metadata'; - -export interface EventsData { - id: string; - events: GitHubEvent[]; - metadata: { - lastFetch: number; - usernames: string[]; - apiMode: 'search' | 'events' | 'summary'; - startDate?: string; - endDate?: string; - }; - timestamp: number; -} - -export interface MetadataRecord { - id: string; - value: unknown; - timestamp: number; -} - -class IndexedDBManager { - private db: IDBDatabase | null = null; - private isInitialized = false; - - /** - * Initialize the IndexedDB database - */ - async init(): Promise { - if (this.isInitialized) return; - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => { - console.error('Failed to open IndexedDB:', request.error); - reject(request.error); - }; - - request.onsuccess = () => { - this.db = request.result; - this.isInitialized = true; - resolve(); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create events store - if (!db.objectStoreNames.contains(EVENTS_STORE)) { - const eventsStore = db.createObjectStore(EVENTS_STORE, { keyPath: 'id' }); - eventsStore.createIndex('timestamp', 'timestamp', { unique: false }); - } - - // Create metadata store - if (!db.objectStoreNames.contains(METADATA_STORE)) { - const metadataStore = db.createObjectStore(METADATA_STORE, { keyPath: 'id' }); - metadataStore.createIndex('timestamp', 'timestamp', { unique: false }); - } - }; - }); - } - - /** - * Store events data in IndexedDB - */ - async storeEvents(key: string, events: GitHubEvent[], metadata: EventsData['metadata']): Promise { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([EVENTS_STORE], 'readwrite'); - const store = transaction.objectStore(EVENTS_STORE); - - const data: EventsData = { - id: key, - events, - metadata, - timestamp: Date.now(), - }; - - const request = store.put(data); - - request.onsuccess = () => resolve(); - request.onerror = () => { - console.error('Failed to store events:', request.error); - reject(request.error); - }; - }); - } - - /** - * Retrieve events data from IndexedDB - */ - async getEvents(key: string): Promise { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([EVENTS_STORE], 'readonly'); - const store = transaction.objectStore(EVENTS_STORE); - const request = store.get(key); - - request.onsuccess = () => { - resolve(request.result || null); - }; - - request.onerror = () => { - console.error('Failed to retrieve events:', request.error); - reject(request.error); - }; - }); - } - - /** - * Store metadata in IndexedDB - */ - async storeMetadata(key: string, value: unknown): Promise { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([METADATA_STORE], 'readwrite'); - const store = transaction.objectStore(METADATA_STORE); - - const data: MetadataRecord = { - id: key, - value, - timestamp: Date.now(), - }; - - const request = store.put(data); - - request.onsuccess = () => resolve(); - request.onerror = () => { - console.error('Failed to store metadata:', request.error); - reject(request.error); - }; - }); - } - - /** - * Retrieve metadata from IndexedDB - */ - async getMetadata(key: string): Promise { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([METADATA_STORE], 'readonly'); - const store = transaction.objectStore(METADATA_STORE); - const request = store.get(key); - - request.onsuccess = () => { - resolve(request.result?.value || null); - }; - - request.onerror = () => { - console.error('Failed to retrieve metadata:', request.error); - reject(request.error); - }; - }); - } - - /** - * Clear all data from IndexedDB - */ - async clearAll(): Promise { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([EVENTS_STORE, METADATA_STORE], 'readwrite'); - const eventsStore = transaction.objectStore(EVENTS_STORE); - const metadataStore = transaction.objectStore(METADATA_STORE); - - const eventsRequest = eventsStore.clear(); - const metadataRequest = metadataStore.clear(); - - let completed = 0; - const checkComplete = () => { - completed++; - if (completed === 2) resolve(); - }; - - eventsRequest.onsuccess = checkComplete; - metadataRequest.onsuccess = checkComplete; - - eventsRequest.onerror = () => reject(eventsRequest.error); - metadataRequest.onerror = () => reject(metadataRequest.error); - }); - } - - /** - * Get storage usage information - */ - async getStorageInfo(): Promise<{ eventsCount: number; metadataCount: number; totalSize: number }> { - await this.init(); - - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error('Database not initialized')); - return; - } - - const transaction = this.db.transaction([EVENTS_STORE, METADATA_STORE], 'readonly'); - const eventsStore = transaction.objectStore(EVENTS_STORE); - const metadataStore = transaction.objectStore(METADATA_STORE); - - const eventsRequest = eventsStore.getAll(); - const metadataRequest = metadataStore.getAll(); - - let eventsData: EventsData[] = []; - let metadataData: MetadataRecord[] = []; - - let completed = 0; - const checkComplete = () => { - completed++; - if (completed === 2) { - const totalSize = JSON.stringify(eventsData).length + JSON.stringify(metadataData).length; - resolve({ - eventsCount: eventsData.length, - metadataCount: metadataData.length, - totalSize, - }); - } - }; - - eventsRequest.onsuccess = () => { - eventsData = eventsRequest.result; - checkComplete(); - }; - - metadataRequest.onsuccess = () => { - metadataData = metadataRequest.result; - checkComplete(); - }; - - eventsRequest.onerror = () => reject(eventsRequest.error); - metadataRequest.onerror = () => reject(metadataRequest.error); - }); - } - - /** - * Check if IndexedDB is supported - */ - static isSupported(): boolean { - return typeof indexedDB !== 'undefined'; - } -} - -// Export singleton instance -export const indexedDBManager = new IndexedDBManager(); - -/** - * Convenience functions for events storage - */ -export const eventsStorage = { - /** - * Store events data - */ - async store(key: string, events: GitHubEvent[], metadata: EventsData['metadata']): Promise { - if (!IndexedDBManager.isSupported()) { - console.warn('IndexedDB not supported, falling back to localStorage'); - // Fallback to localStorage for older browsers - try { - localStorage.setItem(key, JSON.stringify({ events, metadata })); - } catch (error) { - console.error('Failed to store in localStorage:', error); - } - return; - } - - try { - await indexedDBManager.storeEvents(key, events, metadata); - } catch (error) { - console.error('Failed to store in IndexedDB, falling back to localStorage:', error); - // Fallback to localStorage - try { - localStorage.setItem(key, JSON.stringify({ events, metadata })); - } catch (localError) { - console.error('Failed to store in localStorage:', localError); - } - } - }, - - /** - * Retrieve events data - */ - async retrieve(key: string): Promise<{ events: GitHubEvent[]; metadata: EventsData['metadata'] } | null> { - if (!IndexedDBManager.isSupported()) { - console.warn('IndexedDB not supported, trying localStorage'); - // Fallback to localStorage - try { - const data = localStorage.getItem(key); - return data ? JSON.parse(data) : null; - } catch (error) { - console.error('Failed to retrieve from localStorage:', error); - return null; - } - } - - try { - const data = await indexedDBManager.getEvents(key); - return data ? { events: data.events, metadata: data.metadata } : null; - } catch (error) { - console.error('Failed to retrieve from IndexedDB, trying localStorage:', error); - // Fallback to localStorage - try { - const data = localStorage.getItem(key); - return data ? JSON.parse(data) : null; - } catch (localError) { - console.error('Failed to retrieve from localStorage:', localError); - return null; - } - } - }, - - /** - * Clear all events data - */ - async clear(): Promise { - if (!IndexedDBManager.isSupported()) { - // Clear localStorage keys that might contain events data - const keysToRemove = [ - 'github-raw-data-storage', - 'github-events-results', - 'github-raw-events-results', - ]; - keysToRemove.forEach(key => localStorage.removeItem(key)); - return; - } - - try { - await indexedDBManager.clearAll(); - } catch (error) { - console.error('Failed to clear IndexedDB:', error); - } - }, - - /** - * Get storage information - */ - async getInfo(): Promise<{ eventsCount: number; metadataCount: number; totalSize: number } | null> { - if (!IndexedDBManager.isSupported()) { - return null; - } - - try { - return await indexedDBManager.getStorageInfo(); - } catch (error) { - console.error('Failed to get storage info:', error); - return null; - } - }, -}; \ No newline at end of file diff --git a/src/utils/prEnrichment.ts b/src/utils/prEnrichment.ts deleted file mode 100644 index 5dbb2f4..0000000 --- a/src/utils/prEnrichment.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { GitHubItem } from '../types'; - -/** - * Pull Request Enrichment Utilities - * - * Fetches and caches full PR details when they're not available in the event payload - */ - -interface PRDetails { - title: string; - state: string; - body: string; - html_url: string; - labels: Array<{ name: string; color?: string; description?: string }>; - updated_at: string; - closed_at?: string; - merged_at?: string; - merged?: boolean; -} - -// In-memory cache for PR details to avoid duplicate fetches -const prCache = new Map(); - -/** - * Extracts PR API URL from a GitHubItem - * Returns null if the item is not a PR or doesn't have a PR URL - */ -const getPRApiUrl = (item: GitHubItem): string | null => { - // Check if this is a PR-related item - if (!item.originalEventType?.includes('PullRequest')) { - return null; - } - - // Try to extract PR number from html_url - // Format: https://github.com/owner/repo/pull/123 - const match = item.html_url?.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); - if (!match) { - return null; - } - - const [, repoFullName, prNumber] = match; - return `https://api.github.com/repos/${repoFullName}/pulls/${prNumber}`; -}; - -/** - * Checks if an item needs PR details enrichment - */ -export const needsPREnrichment = (item: GitHubItem): boolean => { - // Check if this is a PR-related item - if (!item.originalEventType?.includes('PullRequest')) { - return false; - } - - // Check if title is a generic fallback (indicates missing data) - if (item.title?.match(/^Pull Request #\d+ (opened|closed|labeled|unlabeled|synchronized|reopened|edited|assigned|unassigned|review_requested|review_request_removed)$/)) { - return true; - } - - // Check if title starts with "Review on: Pull Request #" (indicates missing PR title) - if (item.title?.startsWith('Review on: Pull Request #')) { - return true; - } - - // Check if title starts with "Review comment on: Pull Request #" (indicates missing PR title) - if (item.title?.startsWith('Review comment on: Pull Request #')) { - return true; - } - - return false; -}; - -/** - * Fetches PR details from GitHub API - */ -const fetchPRDetails = async ( - apiUrl: string, - githubToken?: string -): Promise => { - // Check cache first - if (prCache.has(apiUrl)) { - return prCache.get(apiUrl)!; - } - - try { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3+json', - }; - - if (githubToken) { - headers['Authorization'] = `token ${githubToken}`; - } - - const response = await fetch(apiUrl, { headers }); - - if (!response.ok) { - console.warn(`Failed to fetch PR details from ${apiUrl}: ${response.status}`); - return null; - } - - const data = await response.json(); - - const details: PRDetails = { - title: data.title, - state: data.state, - body: data.body || '', - html_url: data.html_url, - labels: data.labels || [], - updated_at: data.updated_at, - closed_at: data.closed_at, - merged_at: data.merged_at, - merged: data.merged, - }; - - // Cache the result - prCache.set(apiUrl, details); - - return details; - } catch (error) { - console.warn(`Error fetching PR details from ${apiUrl}:`, error); - return null; - } -}; - -/** - * Enriches a single GitHubItem with full PR details if needed - */ -export const enrichItemWithPRDetails = async ( - item: GitHubItem, - githubToken?: string -): Promise => { - // Don't fetch without a token to respect rate limits - if (!githubToken) { - return item; - } - - // Check if enrichment is needed - if (!needsPREnrichment(item)) { - return item; - } - - // Get PR API URL - const apiUrl = getPRApiUrl(item); - if (!apiUrl) { - return item; - } - - // Fetch PR details - const prDetails = await fetchPRDetails(apiUrl, githubToken); - if (!prDetails) { - return item; - } - - // Enrich the item - const enrichedItem: GitHubItem = { - ...item, - labels: prDetails.labels.length > 0 ? prDetails.labels : item.labels, - updated_at: prDetails.updated_at || item.updated_at, - closed_at: prDetails.closed_at || item.closed_at, - merged_at: prDetails.merged_at || item.merged_at, - merged: prDetails.merged !== undefined ? prDetails.merged : item.merged, - state: prDetails.state || item.state, - }; - - // Update title based on event type - if (item.originalEventType === 'PullRequestReviewEvent') { - enrichedItem.title = `Review on: ${prDetails.title}`; - } else if (item.originalEventType === 'PullRequestReviewCommentEvent') { - enrichedItem.title = `Review comment on: ${prDetails.title}`; - } else if (item.title?.match(/^Pull Request #\d+/)) { - // Extract the action from the current title - const actionMatch = item.title?.match(/^Pull Request #\d+ (.+)$/); - const action = actionMatch ? actionMatch[1] : ''; - enrichedItem.title = action ? `${prDetails.title} (${action})` : prDetails.title; - } - - return enrichedItem; -}; - -/** - * Enriches multiple GitHubItems with PR details in batch - * Only fetches details for items that need enrichment - */ -export const enrichItemsWithPRDetails = async ( - items: GitHubItem[], - githubToken?: string, - onProgress?: (current: number, total: number) => void -): Promise => { - // Don't fetch without a token - if (!githubToken) { - return items; - } - - // Filter items that need enrichment - const itemsNeedingEnrichment = items.filter(needsPREnrichment); - - if (itemsNeedingEnrichment.length === 0) { - return items; - } - - console.log(`Enriching ${itemsNeedingEnrichment.length} items with PR details...`); - - // Create a map for quick lookup - const enrichmentMap = new Map(); - - // Enrich items that need it (with progress tracking) - let processed = 0; - for (const item of itemsNeedingEnrichment) { - const enrichedItem = await enrichItemWithPRDetails(item, githubToken); - enrichmentMap.set(item.id, enrichedItem); - - processed++; - if (onProgress) { - onProgress(processed, itemsNeedingEnrichment.length); - } - - // Add a small delay to respect rate limits - if (processed < itemsNeedingEnrichment.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - // Return all items with enriched ones replaced - return items.map(item => enrichmentMap.get(item.id) || item); -}; - -/** - * Clears the PR details cache - */ -export const clearPRCache = (): void => { - prCache.clear(); -}; - -/** - * Gets the current cache size (for debugging/monitoring) - */ -export const getPRCacheSize = (): number => { - return prCache.size; -}; - diff --git a/src/utils/resultsUtils.test.ts b/src/utils/resultsUtils.test.ts index e745766..4d3dcda 100644 --- a/src/utils/resultsUtils.test.ts +++ b/src/utils/resultsUtils.test.ts @@ -16,7 +16,7 @@ import { getItemType, ResultsFilter, parseSearchText, -} from './resultsUtils'; +} from './filtering'; import type { GitHubItem } from '../types'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..f85b44d --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,806 @@ +/** + * Storage Utilities + * + * Consolidates all storage-related functionality: + * - localStorage management and quota handling + * - IndexedDB for large data (events, search items) + * - Username/avatar caching utilities + */ + +import React from 'react'; +import { GitHubEvent } from '../types'; + +// ============================================================================ +// LOCALSTORAGE UTILITIES +// ============================================================================ + +export interface StorageInfo { + key: string; + size: number; + lastModified?: number; +} + +/** + * Get the size of a localStorage item in bytes + */ +export const getStorageItemSize = (key: string): number => { + try { + const item = localStorage.getItem(key); + return item ? new Blob([item]).size : 0; + } catch { + return 0; + } +}; + +/** + * Get total size of all localStorage data + */ +export const getTotalStorageSize = (): number => { + try { + let total = 0; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + total += getStorageItemSize(key); + } + } + return total; + } catch { + return 0; + } +}; + +/** + * Get information about all localStorage items + */ +export const getStorageInfo = (): StorageInfo[] => { + try { + const info: StorageInfo[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + info.push({ + key, + size: getStorageItemSize(key), + }); + } + } + return info.sort((a, b) => b.size - a.size); + } catch { + return []; + } +}; + +/** + * Clear old data to make space for new data + */ +export const clearOldData = (targetKey: string, requiredSize: number): boolean => { + try { + const maxSize = 4.5 * 1024 * 1024; // 4.5MB limit to be safe + const currentSize = getTotalStorageSize(); + + if (currentSize + requiredSize > maxSize) { + const cleanupOrder = [ + 'github-search-results', + 'github-events-results', + 'github-raw-events-results', + 'github-raw-data-storage', + 'github-item-ui-state', + 'github-ui-settings', + ]; + + for (const key of cleanupOrder) { + if (key !== targetKey && localStorage.getItem(key)) { + localStorage.removeItem(key); + console.warn(`Removed old data from localStorage: ${key}`); + + if (getTotalStorageSize() + requiredSize <= maxSize) { + return true; + } + } + } + } + + return getTotalStorageSize() + requiredSize <= maxSize; + } catch { + return false; + } +}; + +/** + * Check if there's enough space for new data + */ +export const hasEnoughSpace = (requiredSize: number): boolean => { + const maxSize = 4.5 * 1024 * 1024; + return getTotalStorageSize() + requiredSize <= maxSize; +}; + +/** + * Get storage usage statistics + */ +export const getStorageStats = () => { + const totalSize = getTotalStorageSize(); + const maxSize = 5 * 1024 * 1024; + const usagePercent = (totalSize / maxSize) * 100; + + return { + totalSize, + maxSize, + usagePercent, + availableSpace: maxSize - totalSize, + isNearLimit: usagePercent > 80, + }; +}; + +/** + * Clear all GitHub-related data from localStorage + */ +export const clearAllGitHubData = (): void => { + const githubKeys = [ + 'github-search-results', + 'github-events-results', + 'github-raw-events-results', + 'github-raw-data-storage', + 'github-last-search-params', + 'github-item-ui-state', + 'github-ui-settings', + 'github-form-settings', + 'github-username-cache', + ]; + + githubKeys.forEach((key) => { + if (localStorage.getItem(key)) { + localStorage.removeItem(key); + console.log(`Cleared localStorage key: ${key}`); + } + }); +}; + +/** + * Clear GitHub caches and data while preserving the GitHub token + * Returns the preserved GitHub token that should be used in the updated form settings + */ +export const clearCachesKeepToken = async (): Promise => { + try { + // Preserve the GitHub token from form settings + let preservedToken = ''; + const formSettings = localStorage.getItem('github-form-settings'); + if (formSettings) { + try { + const parsed = JSON.parse(formSettings); + preservedToken = parsed.githubToken || ''; + } catch { + // If parsing fails, token will remain empty string + } + } + + // Clear localStorage keys + const keysToRemove = [ + 'github-search-results', + 'github-events-results', + 'github-raw-events-results', + 'github-raw-data-storage', + 'github-last-search-params', + 'github-item-ui-state', + 'github-ui-settings', + 'github-username-cache', + 'github-form-settings', + ]; + + keysToRemove.forEach((key) => { + if (localStorage.getItem(key)) { + localStorage.removeItem(key); + console.log(`Cleared cache localStorage key: ${key}`); + } + }); + + // Clear IndexedDB data + await eventsStorage.clear(); + console.log('Cleared IndexedDB cache data'); + + console.log('✅ Cache cleanup completed, preserved GitHub token for reuse'); + return preservedToken; + } catch (error) { + console.error('Failed to clear caches:', error); + return ''; + } +}; + +/** + * Safe localStorage setter that handles quota exceeded errors + */ +export const safeSetItem = (key: string, value: string): boolean => { + try { + const dataSize = new Blob([value]).size; + + if (!hasEnoughSpace(dataSize)) { + if (!clearOldData(key, dataSize)) { + console.warn('Not enough localStorage space. Data will not be saved.'); + return false; + } + } + + localStorage.setItem(key, value); + return true; + } catch (error) { + if (error instanceof Error && error.name === 'QuotaExceededError') { + console.error(`localStorage quota exceeded for key "${key}". Attempting cleanup...`); + + const dataSize = new Blob([value]).size; + if (clearOldData(key, dataSize)) { + try { + localStorage.setItem(key, value); + console.log('Successfully saved data after cleanup'); + return true; + } catch (retryError) { + console.error('Failed to save data even after cleanup:', retryError); + return false; + } + } else { + console.error('Not enough space even after cleanup. Data will not be saved.'); + return false; + } + } else { + console.error(`Error saving to localStorage key "${key}":`, error); + return false; + } + } +}; + +// ============================================================================ +// INDEXED DB +// ============================================================================ + +const DB_NAME = 'GitVegasDB'; +const DB_VERSION = 1; +const EVENTS_STORE = 'events'; +const METADATA_STORE = 'metadata'; + +export interface EventsData { + id: string; + events: GitHubEvent[]; + metadata: { + lastFetch: number; + usernames: string[]; + apiMode: 'search' | 'events' | 'summary'; + startDate?: string; + endDate?: string; + }; + timestamp: number; +} + +export interface MetadataRecord { + id: string; + value: unknown; + timestamp: number; +} + +class IndexedDBManager { + private db: IDBDatabase | null = null; + private isInitialized = false; + + async init(): Promise { + if (this.isInitialized) return; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('Failed to open IndexedDB:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + this.db = request.result; + this.isInitialized = true; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains(EVENTS_STORE)) { + const eventsStore = db.createObjectStore(EVENTS_STORE, { keyPath: 'id' }); + eventsStore.createIndex('timestamp', 'timestamp', { unique: false }); + } + + if (!db.objectStoreNames.contains(METADATA_STORE)) { + const metadataStore = db.createObjectStore(METADATA_STORE, { keyPath: 'id' }); + metadataStore.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + }); + } + + async storeEvents(key: string, events: GitHubEvent[], metadata: EventsData['metadata']): Promise { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([EVENTS_STORE], 'readwrite'); + const store = transaction.objectStore(EVENTS_STORE); + + const data: EventsData = { + id: key, + events, + metadata, + timestamp: Date.now(), + }; + + const request = store.put(data); + + request.onsuccess = () => resolve(); + request.onerror = () => { + console.error('Failed to store events:', request.error); + reject(request.error); + }; + }); + } + + async getEvents(key: string): Promise { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([EVENTS_STORE], 'readonly'); + const store = transaction.objectStore(EVENTS_STORE); + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + console.error('Failed to retrieve events:', request.error); + reject(request.error); + }; + }); + } + + async storeMetadata(key: string, value: unknown): Promise { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([METADATA_STORE], 'readwrite'); + const store = transaction.objectStore(METADATA_STORE); + + const data: MetadataRecord = { + id: key, + value, + timestamp: Date.now(), + }; + + const request = store.put(data); + + request.onsuccess = () => resolve(); + request.onerror = () => { + console.error('Failed to store metadata:', request.error); + reject(request.error); + }; + }); + } + + async getMetadata(key: string): Promise { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([METADATA_STORE], 'readonly'); + const store = transaction.objectStore(METADATA_STORE); + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result?.value || null); + }; + + request.onerror = () => { + console.error('Failed to retrieve metadata:', request.error); + reject(request.error); + }; + }); + } + + async clearAll(): Promise { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([EVENTS_STORE, METADATA_STORE], 'readwrite'); + const eventsStore = transaction.objectStore(EVENTS_STORE); + const metadataStore = transaction.objectStore(METADATA_STORE); + + const eventsRequest = eventsStore.clear(); + const metadataRequest = metadataStore.clear(); + + let completed = 0; + const checkComplete = () => { + completed++; + if (completed === 2) resolve(); + }; + + eventsRequest.onsuccess = checkComplete; + metadataRequest.onsuccess = checkComplete; + + eventsRequest.onerror = () => reject(eventsRequest.error); + metadataRequest.onerror = () => reject(metadataRequest.error); + }); + } + + async getStorageInfo(): Promise<{ eventsCount: number; metadataCount: number; totalSize: number }> { + await this.init(); + + return new Promise((resolve, reject) => { + if (!this.db) { + reject(new Error('Database not initialized')); + return; + } + + const transaction = this.db.transaction([EVENTS_STORE, METADATA_STORE], 'readonly'); + const eventsStore = transaction.objectStore(EVENTS_STORE); + const metadataStore = transaction.objectStore(METADATA_STORE); + + const eventsRequest = eventsStore.getAll(); + const metadataRequest = metadataStore.getAll(); + + let eventsData: EventsData[] = []; + let metadataData: MetadataRecord[] = []; + + let completed = 0; + const checkComplete = () => { + completed++; + if (completed === 2) { + const totalSize = JSON.stringify(eventsData).length + JSON.stringify(metadataData).length; + resolve({ + eventsCount: eventsData.length, + metadataCount: metadataData.length, + totalSize, + }); + } + }; + + eventsRequest.onsuccess = () => { + eventsData = eventsRequest.result; + checkComplete(); + }; + + metadataRequest.onsuccess = () => { + metadataData = metadataRequest.result; + checkComplete(); + }; + + eventsRequest.onerror = () => reject(eventsRequest.error); + metadataRequest.onerror = () => reject(metadataRequest.error); + }); + } + + static isSupported(): boolean { + return typeof indexedDB !== 'undefined'; + } +} + +export const indexedDBManager = new IndexedDBManager(); + +/** + * Convenience functions for events storage + */ +export const eventsStorage = { + async store(key: string, events: GitHubEvent[], metadata: EventsData['metadata']): Promise { + if (!IndexedDBManager.isSupported()) { + console.warn('IndexedDB not supported, falling back to localStorage'); + try { + localStorage.setItem(key, JSON.stringify({ events, metadata })); + } catch (error) { + console.error('Failed to store in localStorage:', error); + } + return; + } + + try { + await indexedDBManager.storeEvents(key, events, metadata); + } catch (error) { + console.error('Failed to store in IndexedDB, falling back to localStorage:', error); + try { + localStorage.setItem(key, JSON.stringify({ events, metadata })); + } catch (localError) { + console.error('Failed to store in localStorage:', localError); + } + } + }, + + async retrieve( + key: string + ): Promise<{ events: GitHubEvent[]; metadata: EventsData['metadata'] } | null> { + if (!IndexedDBManager.isSupported()) { + console.warn('IndexedDB not supported, trying localStorage'); + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('Failed to retrieve from localStorage:', error); + return null; + } + } + + try { + const data = await indexedDBManager.getEvents(key); + return data ? { events: data.events, metadata: data.metadata } : null; + } catch (error) { + console.error('Failed to retrieve from IndexedDB, trying localStorage:', error); + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; + } catch (localError) { + console.error('Failed to retrieve from localStorage:', localError); + return null; + } + } + }, + + async clear(): Promise { + if (!IndexedDBManager.isSupported()) { + const keysToRemove = [ + 'github-raw-data-storage', + 'github-events-results', + 'github-raw-events-results', + ]; + keysToRemove.forEach((key) => localStorage.removeItem(key)); + return; + } + + try { + await indexedDBManager.clearAll(); + } catch (error) { + console.error('Failed to clear IndexedDB:', error); + } + }, + + async getInfo(): Promise<{ eventsCount: number; metadataCount: number; totalSize: number } | null> { + if (!IndexedDBManager.isSupported()) { + return null; + } + + try { + return await indexedDBManager.getStorageInfo(); + } catch (error) { + console.error('Failed to get storage info:', error); + return null; + } + }, +}; + +// ============================================================================ +// USERNAME CACHE UTILITIES +// ============================================================================ + +/** + * Creates a function to add usernames to a Set-based cache + */ +export const createAddToCache = ( + setter: React.Dispatch>> +) => { + return (usernames: string[]) => { + setter((prevSet) => { + const newSet = new Set(prevSet); + usernames.forEach((u) => newSet.add(u)); + return newSet; + }); + }; +}; + +/** + * Creates a function to remove a username from a Set-based cache + */ +export const createRemoveFromCache = ( + setter: React.Dispatch>> +) => { + return (username: string) => { + setter((prevSet) => { + const newSet = new Set(prevSet); + newSet.delete(username); + return newSet; + }); + }; +}; + +/** + * Creates a function to add avatar URLs to a Map-based cache + */ +export const createAddAvatarsToCache = ( + setter: React.Dispatch>> +) => { + return (avatarUrls: Record) => { + setter((prevMap) => { + let safeMap: Map; + + if (prevMap instanceof Map) { + safeMap = new Map(prevMap); + } else { + safeMap = new Map(); + if (prevMap && typeof prevMap === 'object') { + try { + Object.entries(prevMap).forEach(([key, value]) => { + if (typeof value === 'string') { + safeMap.set(key, value); + } + }); + } catch (error) { + console.warn('Failed to reconstruct avatar cache from previous state:', error); + } + } + } + + Object.entries(avatarUrls).forEach(([username, avatarUrl]) => { + safeMap.set(username, avatarUrl); + }); + + return safeMap; + }); + }; +}; + +/** + * Creates a function to update the lastFetched timestamp for usernames + */ +export const createUpdateLastFetched = ( + setter: React.Dispatch>> +) => { + return (usernames: string[]) => { + const now = Date.now(); + setter((prevMap) => { + let safeMap: Map; + + if (prevMap instanceof Map) { + safeMap = new Map(prevMap); + } else { + safeMap = new Map(); + if (prevMap && typeof prevMap === 'object') { + try { + Object.entries(prevMap).forEach(([key, value]) => { + if (typeof value === 'number') { + safeMap.set(key, value); + } + }); + } catch (error) { + console.warn('Failed to reconstruct lastFetched cache from previous state:', error); + } + } + } + + usernames.forEach((username) => { + safeMap.set(username, now); + }); + + return safeMap; + }); + }; +}; + +/** + * Checks if cached data for usernames is stale and needs revalidation + */ +export const getStaleUsernames = ( + usernames: string[], + lastFetchedCache: Map, + maxAgeMs: number = 60 * 60 * 1000 +): string[] => { + const now = Date.now(); + + let safeMap: Map; + if (lastFetchedCache instanceof Map) { + safeMap = lastFetchedCache; + } else { + safeMap = new Map(); + if (lastFetchedCache && typeof lastFetchedCache === 'object') { + try { + Object.entries(lastFetchedCache).forEach(([key, value]) => { + if (typeof value === 'number') { + safeMap.set(key, value); + } + }); + } catch (error) { + console.warn('Failed to reconstruct lastFetched cache:', error); + } + } + } + + return usernames.filter((username) => { + const lastFetched = safeMap.get(username); + return !lastFetched || now - lastFetched > maxAgeMs; + }); +}; + +/** + * Gets cached avatar URLs for given usernames + */ +export const getCachedAvatarUrls = ( + usernames: string[], + avatarCache: Map +): string[] => { + let safeAvatarCache: Map; + + if (avatarCache instanceof Map) { + safeAvatarCache = avatarCache; + } else { + safeAvatarCache = new Map(); + if (avatarCache && typeof avatarCache === 'object') { + try { + Object.entries(avatarCache).forEach(([key, value]) => { + if (typeof value === 'string') { + safeAvatarCache.set(key, value); + } + }); + } catch (error) { + console.warn('Failed to reconstruct avatar cache from object:', error); + } + } + } + + return usernames.map((username) => safeAvatarCache.get(username)).filter((url): url is string => !!url); +}; + +/** + * Categorizes usernames based on their validation cache status + */ +export const categorizeUsernames = ( + usernames: string[], + validatedCache: Set, + invalidCache: Set +) => { + const safeValidatedCache = validatedCache instanceof Set ? validatedCache : new Set(); + const safeInvalidCache = invalidCache instanceof Set ? invalidCache : new Set(); + + const needValidation = usernames.filter( + (u) => !safeValidatedCache.has(u) && !safeInvalidCache.has(u) + ); + const alreadyValid = usernames.filter((u) => safeValidatedCache.has(u)); + const alreadyInvalid = usernames.filter( + (u) => safeInvalidCache.has(u) && !safeValidatedCache.has(u) + ); + + return { + needValidation, + alreadyValid, + alreadyInvalid, + }; +}; + +/** + * Checks if username validation is needed for any usernames + */ +export const needsValidation = ( + usernames: string[], + validatedCache: Set, + invalidCache: Set +): boolean => { + const { needValidation: needsVal } = categorizeUsernames(usernames, validatedCache, invalidCache); + return needsVal.length > 0; +}; + +/** + * Gets usernames that are known to be invalid from cache + */ +export const getInvalidUsernames = (usernames: string[], invalidCache: Set): string[] => { + const safeInvalidCache = invalidCache instanceof Set ? invalidCache : new Set(); + return usernames.filter((u) => safeInvalidCache.has(u)); +}; diff --git a/src/utils/storageUtils.ts b/src/utils/storageUtils.ts deleted file mode 100644 index cf6f2c7..0000000 --- a/src/utils/storageUtils.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Storage utilities for managing localStorage data and handling quota exceeded errors - */ - -export interface StorageInfo { - key: string; - size: number; - lastModified?: number; -} - -/** - * Get the size of a localStorage item in bytes - */ -export const getStorageItemSize = (key: string): number => { - try { - const item = localStorage.getItem(key); - return item ? new Blob([item]).size : 0; - } catch { - return 0; - } -}; - -/** - * Get total size of all localStorage data - */ -export const getTotalStorageSize = (): number => { - try { - let total = 0; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - total += getStorageItemSize(key); - } - } - return total; - } catch { - return 0; - } -}; - -/** - * Get information about all localStorage items - */ -export const getStorageInfo = (): StorageInfo[] => { - try { - const info: StorageInfo[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - info.push({ - key, - size: getStorageItemSize(key), - }); - } - } - return info.sort((a, b) => b.size - a.size); // Sort by size, largest first - } catch { - return []; - } -}; - -/** - * Clear old data to make space for new data - */ -export const clearOldData = (targetKey: string, requiredSize: number): boolean => { - try { - const maxSize = 4.5 * 1024 * 1024; // 4.5MB limit to be safe - const currentSize = getTotalStorageSize(); - - if (currentSize + requiredSize > maxSize) { - // Priority order for cleanup: old raw data first, then non-essential data - const cleanupOrder = [ - // Old raw data (highest priority for cleanup) - 'github-search-results', - 'github-events-results', - 'github-raw-events-results', - 'github-raw-data-storage', // Legacy storage (now using IndexedDB for both events and search items) - // Non-essential data (lower priority) - 'github-item-ui-state', - 'github-ui-settings' - ]; - - for (const key of cleanupOrder) { - if (key !== targetKey && localStorage.getItem(key)) { - localStorage.removeItem(key); - console.warn(`Removed old data from localStorage: ${key}`); - - // Check if we have enough space now - if (getTotalStorageSize() + requiredSize <= maxSize) { - return true; - } - } - } - } - - return getTotalStorageSize() + requiredSize <= maxSize; - } catch { - return false; - } -}; - -/** - * Check if there's enough space for new data - */ -export const hasEnoughSpace = (requiredSize: number): boolean => { - const maxSize = 4.5 * 1024 * 1024; // 4.5MB limit - return getTotalStorageSize() + requiredSize <= maxSize; -}; - -/** - * Get storage usage statistics - */ -export const getStorageStats = () => { - const totalSize = getTotalStorageSize(); - const maxSize = 5 * 1024 * 1024; // 5MB theoretical limit - const usagePercent = (totalSize / maxSize) * 100; - - return { - totalSize, - maxSize, - usagePercent, - availableSpace: maxSize - totalSize, - isNearLimit: usagePercent > 80, - }; -}; - -/** - * Clear all GitHub-related data from localStorage - */ -export const clearAllGitHubData = (): void => { - const githubKeys = [ - 'github-search-results', - 'github-events-results', - 'github-raw-events-results', - 'github-raw-data-storage', // Legacy storage (now using IndexedDB for both events and search items) - 'github-last-search-params', // Legacy cache validation - 'github-item-ui-state', - 'github-ui-settings', - 'github-form-settings', - 'github-username-cache' - ]; - - githubKeys.forEach(key => { - if (localStorage.getItem(key)) { - localStorage.removeItem(key); - console.log(`Cleared localStorage key: ${key}`); - } - }); -}; - -/** - * Clear GitHub caches and data while preserving the GitHub token - * Used when loading the app with URL parameters (shared links) - * Returns the preserved GitHub token that should be used in the updated form settings - */ -export const clearCachesKeepToken = async (): Promise => { - try { - // First, preserve the GitHub token from form settings - let preservedToken = ''; - const formSettings = localStorage.getItem('github-form-settings'); - if (formSettings) { - try { - const parsed = JSON.parse(formSettings); - preservedToken = parsed.githubToken || ''; - } catch { - // If parsing fails, token will remain empty string - } - } - - // Clear localStorage keys (including form settings - we'll rebuild it with URL params) - const keysToRemove = [ - 'github-search-results', - 'github-events-results', - 'github-raw-events-results', - 'github-raw-data-storage', // Legacy storage - 'github-last-search-params', // Legacy cache validation - 'github-item-ui-state', - 'github-ui-settings', - 'github-username-cache', // Clear username validation cache - 'github-form-settings' // Clear form settings - will be rebuilt with URL params - ]; - - keysToRemove.forEach(key => { - if (localStorage.getItem(key)) { - localStorage.removeItem(key); - console.log(`Cleared cache localStorage key: ${key}`); - } - }); - - // Clear IndexedDB data (events and search items) - const { eventsStorage } = await import('./indexedDB'); - await eventsStorage.clear(); - console.log('Cleared IndexedDB cache data'); - - console.log('✅ Cache cleanup completed, preserved GitHub token for reuse'); - return preservedToken; - } catch (error) { - console.error('Failed to clear caches:', error); - return ''; - } -}; - -/** - * Safe localStorage setter that handles quota exceeded errors - */ -export const safeSetItem = (key: string, value: string): boolean => { - try { - const dataSize = new Blob([value]).size; - - // Check if we have enough space - if (!hasEnoughSpace(dataSize)) { - // Try to clean up old data - if (!clearOldData(key, dataSize)) { - console.warn('Not enough localStorage space. Data will not be saved.'); - return false; - } - } - - localStorage.setItem(key, value); - return true; - } catch (error) { - if (error instanceof Error && error.name === 'QuotaExceededError') { - console.error(`localStorage quota exceeded for key "${key}". Attempting cleanup...`); - - const dataSize = new Blob([value]).size; - if (clearOldData(key, dataSize)) { - try { - localStorage.setItem(key, value); - console.log('Successfully saved data after cleanup'); - return true; - } catch (retryError) { - console.error('Failed to save data even after cleanup:', retryError); - return false; - } - } else { - console.error('Not enough space even after cleanup. Data will not be saved.'); - return false; - } - } else { - console.error(`Error saving to localStorage key "${key}":`, error); - return false; - } - } -}; \ No newline at end of file diff --git a/src/utils/summaryGrouping.ts b/src/utils/summary.ts similarity index 69% rename from src/utils/summaryGrouping.ts rename to src/utils/summary.ts index 8d15768..b350700 100644 --- a/src/utils/summaryGrouping.ts +++ b/src/utils/summary.ts @@ -1,5 +1,61 @@ +/** + * Summary View Utilities + * + * Consolidates all summary-related functionality: + * - Group name constants and types + * - Event categorization and grouping logic + * - Clipboard formatting helpers + * - UI state helpers for summary sections + */ + import { GitHubItem } from '../types'; -import { SUMMARY_GROUP_NAMES, createEmptyGroups, type SummaryGroupName } from './summaryConstants'; + +// ============================================================================ +// CONSTANTS & TYPES +// ============================================================================ + +/** + * Summary view group name constants + */ +export const SUMMARY_GROUP_NAMES = { + PRS_OPENED: 'PRs - opened', + PRS_UPDATED: 'PRs - updated', + PRS_REVIEWED: 'PRs - reviewed', + PRS_MERGED: 'PRs - merged', + PRS_CLOSED: 'PRs - closed', + ISSUES_OPENED: 'Issues - opened', + ISSUES_UPDATED: 'Issues - updated', + ISSUES_CLOSED: 'Issues - closed', + COMMENTS: 'Comments', + COMMITS: 'Commits', + OTHER_EVENTS: 'Other Events', +} as const; + +export type SummaryGroupName = (typeof SUMMARY_GROUP_NAMES)[keyof typeof SUMMARY_GROUP_NAMES]; + +/** + * Returns all group names as an array + */ +export const getAllGroupNames = (): SummaryGroupName[] => { + return Object.values(SUMMARY_GROUP_NAMES); +}; + +/** + * Creates an empty groups object with all summary categories + */ +export const createEmptyGroups = (): Record => { + return Object.values(SUMMARY_GROUP_NAMES).reduce( + (acc, groupName) => { + acc[groupName] = []; + return acc; + }, + {} as Record + ); +}; + +// ============================================================================ +// GROUPING LOGIC +// ============================================================================ /** * Extracts the base PR URL by removing fragments (e.g., #pullrequestreview-123456) @@ -12,7 +68,7 @@ export const getBasePRUrl = (htmlUrl: string): string => { * Parses usernames from a comma-separated string */ export const parseUsernames = (username: string): string[] => { - return username.split(',').map(u => u.trim().toLowerCase()); + return username.split(',').map((u) => u.trim().toLowerCase()); }; /** @@ -57,23 +113,18 @@ export const getEventType = (item: GitHubItem): string => { } // Fallback to title parsing for items without originalEventType (e.g., from Search API) - // Check if this is a pull request review (title starts with "Review on:") if (item.title.startsWith('Review on:')) { return 'pull_request'; } - // Check if this is a review comment (title starts with "Review comment on:") if (item.title.startsWith('Review comment on:')) { return 'comment'; } - // Check if this is a comment event (title starts with "Comment on:") if (item.title.startsWith('Comment on:')) { return 'comment'; } - // Check if this is a push event (title starts with "Pushed") if (item.title.startsWith('Pushed')) { return 'commit'; } - // Check for other event types that don't belong to issues/PRs if ( item.title.startsWith('Created branch') || item.title.startsWith('Created tag') || @@ -104,7 +155,7 @@ export const isDateInRange = (dateStr: string, startDate: string, endDate: strin }; /** - * Categories a GitHub item without date filtering (for already date-filtered results) + * Categorizes a GitHub item without date filtering (for already date-filtered results) */ export const categorizeItemWithoutDateFiltering = ( item: GitHubItem, @@ -161,7 +212,6 @@ export const categorizeItemWithoutDateFiltering = ( } // Handle issues - categorize by recent activity regardless of authorship - // Ensure this is actually an issue and not a PR if (type === 'issue' && !item.pull_request) { const createdInRange = isDateInRange(item.created_at, startDate, endDate); const closedInRange = item.closed_at && isDateInRange(item.closed_at, startDate, endDate); @@ -179,7 +229,7 @@ export const categorizeItemWithoutDateFiltering = ( }; /** - * Categories a GitHub item based on its type and content + * Categorizes a GitHub item based on its type and content with date filtering */ export const categorizeItem = ( item: GitHubItem, @@ -223,31 +273,28 @@ export const categorizeItem = ( const closedInRange = item.closed_at && isDateInRange(item.closed_at, startDate, endDate); const updatedInRange = isDateInRange(item.updated_at, startDate, endDate); - - // Check if PR was merged within the timeframe if (mergedAt && mergedInRange) { return SUMMARY_GROUP_NAMES.PRS_MERGED; - } + } // Check if PR was closed within the timeframe (and not merged) else if (item.state === 'closed' && closedInRange && !mergedAt) { return SUMMARY_GROUP_NAMES.PRS_CLOSED; - } + } // Check if PR was created within the timeframe else if (createdInRange) { return SUMMARY_GROUP_NAMES.PRS_OPENED; - } + } // Check if PR was updated but not created/merged/closed within the timeframe else if (updatedInRange && !createdInRange && !mergedInRange && !closedInRange) { return SUMMARY_GROUP_NAMES.PRS_UPDATED; } - + // If none of the above, this PR doesn't belong in this timeframe summary return null; } // Handle issues - apply date range filtering regardless of authorship - // Ensure this is actually an issue and not a PR if (type === 'issue' && !item.pull_request) { const createdInRange = isDateInRange(item.created_at, startDate, endDate); const closedInRange = item.closed_at && isDateInRange(item.closed_at, startDate, endDate); @@ -263,7 +310,7 @@ export const categorizeItem = ( // Issue had activity but wasn't created/closed within timeframe return SUMMARY_GROUP_NAMES.ISSUES_UPDATED; } - + // If none of the above conditions are met, filter out the issue return null; } @@ -284,8 +331,8 @@ export const groupItems = ( const groups = createEmptyGroups(); const addedReviewKeys = new Set(); // Tracks person:PR combinations for review deduplication - items.forEach(item => { - const groupName = applyDateFiltering + items.forEach((item) => { + const groupName = applyDateFiltering ? categorizeItem(item, addedReviewKeys, startDate, endDate) : categorizeItemWithoutDateFiltering(item, addedReviewKeys, startDate, endDate); if (groupName) { @@ -308,9 +355,11 @@ export const addMergedPRsFromSearchItems = ( const startDateTime = new Date(startDate).getTime(); const endDateTime = new Date(endDate).getTime() + 24 * 60 * 60 * 1000 - 1; // End of day - const existingMergedPRUrls = new Set(groups[SUMMARY_GROUP_NAMES.PRS_MERGED].map(item => item.html_url)); - - searchItems.forEach(searchItem => { + const existingMergedPRUrls = new Set( + groups[SUMMARY_GROUP_NAMES.PRS_MERGED].map((item) => item.html_url) + ); + + searchItems.forEach((searchItem) => { const mergedAt = searchItem.merged_at || searchItem.pull_request?.merged_at; if (searchItem.pull_request && mergedAt && !existingMergedPRUrls.has(searchItem.html_url)) { const mergeDate = new Date(mergedAt); @@ -332,21 +381,24 @@ export const addIssuesFromSearchItems = ( endDate: string ): void => { const existingIssueUrls = new Set([ - ...groups[SUMMARY_GROUP_NAMES.ISSUES_OPENED].map(item => item.html_url), - ...groups[SUMMARY_GROUP_NAMES.ISSUES_CLOSED].map(item => item.html_url), - ...groups[SUMMARY_GROUP_NAMES.ISSUES_UPDATED].map(item => item.html_url), + ...groups[SUMMARY_GROUP_NAMES.ISSUES_OPENED].map((item) => item.html_url), + ...groups[SUMMARY_GROUP_NAMES.ISSUES_CLOSED].map((item) => item.html_url), + ...groups[SUMMARY_GROUP_NAMES.ISSUES_UPDATED].map((item) => item.html_url), ]); - searchItems.forEach(searchItem => { + searchItems.forEach((searchItem) => { // Explicitly filter out PRs to ensure they don't appear in issue sections if (!searchItem.pull_request && !existingIssueUrls.has(searchItem.html_url)) { // Categorize the issue using the same logic as other issues const addedReviewPRs = new Set(); // Empty set since we're only dealing with issues const groupName = categorizeItem(searchItem, addedReviewPRs, startDate, endDate); - - if (groupName && (groupName === SUMMARY_GROUP_NAMES.ISSUES_OPENED || - groupName === SUMMARY_GROUP_NAMES.ISSUES_CLOSED || - groupName === SUMMARY_GROUP_NAMES.ISSUES_UPDATED)) { + + if ( + groupName && + (groupName === SUMMARY_GROUP_NAMES.ISSUES_OPENED || + groupName === SUMMARY_GROUP_NAMES.ISSUES_CLOSED || + groupName === SUMMARY_GROUP_NAMES.ISSUES_UPDATED) + ) { groups[groupName].push(searchItem); } } @@ -364,14 +416,95 @@ export const groupSummaryData = ( ): Record => { // Group items first (apply proper date filtering for categorization) const groups = groupItems(items, startDate, endDate, true); - + // Add merged PRs from search items addMergedPRsFromSearchItems(groups, searchItems, startDate, endDate); - + // Add issues from search items addIssuesFromSearchItems(groups, searchItems, startDate, endDate); - - return groups; -}; \ No newline at end of file +}; + +// ============================================================================ +// CLIPBOARD & UI HELPERS +// ============================================================================ + +/** + * Creates a formatted group data structure for clipboard operations + */ +export const formatGroupedDataForClipboard = ( + actionGroups: Record, + selectedItems?: Set +): Array<{ groupName: string; items: GitHubItem[] }> => { + let groupedData = Object.entries(actionGroups) + .filter(([, items]) => items.length > 0) + .map(([groupName, items]) => ({ + groupName, + items, + })); + + // Filter to only selected items if any are selected + if (selectedItems && selectedItems.size > 0) { + groupedData = groupedData + .map(({ groupName, items }) => ({ + groupName, + items: items.filter((item) => selectedItems.has(item.event_id || item.id)), + })) + .filter(({ items }) => items.length > 0); + } + + return groupedData; +}; + +/** + * Gets all displayed items from grouped data + */ +export const getAllDisplayedItems = (actionGroups: Record): GitHubItem[] => { + return Object.values(actionGroups).flat(); +}; + +/** + * Checks if any groups have items + */ +export const hasAnyItems = (actionGroups: Record): boolean => { + return Object.values(actionGroups).some((items) => items.length > 0); +}; + +/** + * Gets total count of all items across groups + */ +export const getTotalItemCount = (actionGroups: Record): number => { + return Object.values(actionGroups).reduce((total, items) => total + items.length, 0); +}; + +/** + * Checks if a section should be collapsed based on stored preferences + */ +export const isSectionCollapsed = (sectionName: string, collapsedSections: Set): boolean => { + return collapsedSections.has(sectionName); +}; + +/** + * Gets the select all state for a specific group + */ +export const getGroupSelectState = ( + groupItems: GitHubItem[], + selectedItems: Set +): { checked: boolean; indeterminate: boolean } => { + if (groupItems.length === 0) { + return { checked: false, indeterminate: false }; + } + + const selectedCount = groupItems.filter((item) => + selectedItems.has(item.event_id || item.id) + ).length; + + if (selectedCount === 0) { + return { checked: false, indeterminate: false }; + } else if (selectedCount === groupItems.length) { + return { checked: true, indeterminate: false }; + } else { + return { checked: false, indeterminate: true }; + } +}; diff --git a/src/utils/summaryConstants.ts b/src/utils/summaryConstants.ts deleted file mode 100644 index eadd08d..0000000 --- a/src/utils/summaryConstants.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Summary view group name constants -export const SUMMARY_GROUP_NAMES = { - PRS_OPENED: 'PRs - opened', - PRS_UPDATED: 'PRs - updated', - PRS_REVIEWED: 'PRs - reviewed', - PRS_MERGED: 'PRs - merged', - PRS_CLOSED: 'PRs - closed', - ISSUES_OPENED: 'Issues - opened', - ISSUES_UPDATED: 'Issues - updated', - ISSUES_CLOSED: 'Issues - closed', - COMMENTS: 'Comments', - COMMITS: 'Commits', - OTHER_EVENTS: 'Other Events', -} as const; - -export type SummaryGroupName = typeof SUMMARY_GROUP_NAMES[keyof typeof SUMMARY_GROUP_NAMES]; - -// Helper to get all group names as an array -export const getAllGroupNames = (): SummaryGroupName[] => { - return Object.values(SUMMARY_GROUP_NAMES); -}; - -// Helper to create empty groups object -export const createEmptyGroups = (): Record => { - return Object.values(SUMMARY_GROUP_NAMES).reduce((acc, groupName) => { - acc[groupName] = []; - return acc; - }, {} as Record); -}; \ No newline at end of file diff --git a/src/utils/summaryHelpers.ts b/src/utils/summaryHelpers.ts deleted file mode 100644 index 4ecb852..0000000 --- a/src/utils/summaryHelpers.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { GitHubItem } from '../types'; - -/** - * Creates a formatted group data structure for clipboard operations - */ -export const formatGroupedDataForClipboard = ( - actionGroups: Record, - selectedItems?: Set -): Array<{ groupName: string; items: GitHubItem[] }> => { - let groupedData = Object.entries(actionGroups) - .filter(([, items]) => items.length > 0) - .map(([groupName, items]) => ({ - groupName, - items, - })); - - // Filter to only selected items if any are selected - if (selectedItems && selectedItems.size > 0) { - groupedData = groupedData - .map(({ groupName, items }) => ({ - groupName, - items: items.filter(item => selectedItems.has(item.event_id || item.id)), - })) - .filter(({ items }) => items.length > 0); - } - - return groupedData; -}; - -/** - * Gets all displayed items from grouped data - */ -export const getAllDisplayedItems = (actionGroups: Record): GitHubItem[] => { - return Object.values(actionGroups).flat(); -}; - -/** - * Checks if any groups have items - */ -export const hasAnyItems = (actionGroups: Record): boolean => { - return Object.values(actionGroups).some(items => items.length > 0); -}; - -/** - * Gets total count of all items across groups - */ -export const getTotalItemCount = (actionGroups: Record): number => { - return Object.values(actionGroups).reduce((total, items) => total + items.length, 0); -}; - -/** - * Checks if a section should be collapsed based on stored preferences - */ -export const isSectionCollapsed = ( - sectionName: string, - collapsedSections: Set -): boolean => { - return collapsedSections.has(sectionName); -}; - -/** - * Gets the select all state for a specific group - */ -export const getGroupSelectState = ( - groupItems: GitHubItem[], - selectedItems: Set -): { checked: boolean; indeterminate: boolean } => { - if (groupItems.length === 0) { - return { checked: false, indeterminate: false }; - } - - const selectedCount = groupItems.filter(item => - selectedItems.has(item.event_id || item.id) - ).length; - - if (selectedCount === 0) { - return { checked: false, indeterminate: false }; - } else if (selectedCount === groupItems.length) { - return { checked: true, indeterminate: false }; - } else { - return { checked: false, indeterminate: true }; - } -}; \ No newline at end of file diff --git a/src/utils/urlState.test.ts b/src/utils/urlState.test.ts index 8ebcad7..bcce512 100644 --- a/src/utils/urlState.test.ts +++ b/src/utils/urlState.test.ts @@ -12,7 +12,7 @@ import { ShareableState, } from './urlState'; import { FormSettings } from '../types'; -import { createDefaultFilter } from './resultsUtils'; +import { createDefaultFilter } from './filtering'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/src/utils/urlState.ts b/src/utils/urlState.ts index 43b0957..3e44b94 100644 --- a/src/utils/urlState.ts +++ b/src/utils/urlState.ts @@ -1,5 +1,5 @@ import { FormSettings } from '../types'; -import { ResultsFilter } from './resultsUtils'; +import { ResultsFilter } from './filtering'; // Define which app state properties can be shared via URL export interface ShareableState { diff --git a/src/utils/usernameCache.test.ts b/src/utils/usernameCache.test.ts index cc748f0..d3655e7 100644 --- a/src/utils/usernameCache.test.ts +++ b/src/utils/usernameCache.test.ts @@ -5,7 +5,7 @@ import { categorizeUsernames, needsValidation, getInvalidUsernames, -} from './usernameCache'; +} from './storage'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/src/utils/usernameCache.ts b/src/utils/usernameCache.ts deleted file mode 100644 index 5c54e1e..0000000 --- a/src/utils/usernameCache.ts +++ /dev/null @@ -1,279 +0,0 @@ -import React from 'react'; - -/** - * Username Cache Management Utilities - * - * Provides functions for managing cached validation state of GitHub usernames - * and their associated avatar URLs to avoid repeated API calls. - */ - -/** - * Creates a function to add usernames to a Set-based cache - * - * @param setter - React state setter function for the Set - * @returns Function that adds an array of usernames to the cache - */ -export const createAddToCache = ( - setter: React.Dispatch>> -) => { - return (usernames: string[]) => { - setter(prevSet => { - const newSet = new Set(prevSet); - usernames.forEach(u => newSet.add(u)); - return newSet; - }); - }; -}; - -/** - * Creates a function to remove a username from a Set-based cache - * - * @param setter - React state setter function for the Set - * @returns Function that removes a username from the cache - */ -export const createRemoveFromCache = ( - setter: React.Dispatch>> -) => { - return (username: string) => { - setter(prevSet => { - const newSet = new Set(prevSet); - newSet.delete(username); - return newSet; - }); - }; -}; - -/** - * Creates a function to add avatar URLs to a Map-based cache - * - * @param setter - React state setter function for the Map - * @returns Function that adds username-avatar URL mappings to the cache - */ -export const createAddAvatarsToCache = ( - setter: React.Dispatch>> -) => { - return (avatarUrls: Record) => { - setter(prevMap => { - // Ensure prevMap is a proper Map - let safeMap: Map; - - if (prevMap instanceof Map) { - safeMap = new Map(prevMap); - } else { - // If prevMap is not a Map, create a new one - safeMap = new Map(); - if (prevMap && typeof prevMap === 'object') { - // Try to reconstruct from plain object - try { - Object.entries(prevMap).forEach(([key, value]) => { - if (typeof value === 'string') { - safeMap.set(key, value); - } - }); - } catch (error) { - console.warn('Failed to reconstruct avatar cache from previous state:', error); - } - } - } - - // Add new avatar URLs - Object.entries(avatarUrls).forEach(([username, avatarUrl]) => { - safeMap.set(username, avatarUrl); - }); - - return safeMap; - }); - }; -}; - -/** - * Creates a function to update the lastFetched timestamp for usernames - * - * @param setter - React state setter function for the lastFetched Map - * @returns Function that updates timestamps for usernames - */ -export const createUpdateLastFetched = ( - setter: React.Dispatch>> -) => { - return (usernames: string[]) => { - const now = Date.now(); - setter(prevMap => { - let safeMap: Map; - - if (prevMap instanceof Map) { - safeMap = new Map(prevMap); - } else { - safeMap = new Map(); - if (prevMap && typeof prevMap === 'object') { - try { - Object.entries(prevMap).forEach(([key, value]) => { - if (typeof value === 'number') { - safeMap.set(key, value); - } - }); - } catch (error) { - console.warn('Failed to reconstruct lastFetched cache from previous state:', error); - } - } - } - - // Update timestamps for the specified usernames - usernames.forEach(username => { - safeMap.set(username, now); - }); - - return safeMap; - }); - }; -}; - -/** - * Checks if cached data for usernames is stale and needs revalidation - * - * @param usernames - Array of usernames to check - * @param lastFetchedCache - Map of username to last fetch timestamp - * @param maxAgeMs - Maximum age in milliseconds before considering data stale (default: 1 hour) - * @returns Array of usernames that need revalidation - */ -export const getStaleUsernames = ( - usernames: string[], - lastFetchedCache: Map, - maxAgeMs: number = 60 * 60 * 1000 // 1 hour default -): string[] => { - const now = Date.now(); - - // Ensure lastFetchedCache is a proper Map - let safeMap: Map; - if (lastFetchedCache instanceof Map) { - safeMap = lastFetchedCache; - } else { - safeMap = new Map(); - if (lastFetchedCache && typeof lastFetchedCache === 'object') { - try { - Object.entries(lastFetchedCache).forEach(([key, value]) => { - if (typeof value === 'number') { - safeMap.set(key, value); - } - }); - } catch (error) { - console.warn('Failed to reconstruct lastFetched cache:', error); - } - } - } - - return usernames.filter(username => { - const lastFetched = safeMap.get(username); - return !lastFetched || (now - lastFetched) > maxAgeMs; - }); -}; - -/** - * Gets cached avatar URLs for given usernames - * - * @param usernames - Array of usernames to get avatars for - * @param avatarCache - Map of cached username-avatar URL mappings - * @returns Array of avatar URLs for the usernames (in same order) - */ -export const getCachedAvatarUrls = ( - usernames: string[], - avatarCache: Map -): string[] => { - // Defensive programming: ensure avatarCache is a proper Map - let safeAvatarCache: Map; - - if (avatarCache instanceof Map) { - safeAvatarCache = avatarCache; - } else { - // If avatarCache is not a Map (e.g., plain object from localStorage), convert it - safeAvatarCache = new Map(); - if (avatarCache && typeof avatarCache === 'object') { - // Try to reconstruct from plain object - try { - Object.entries(avatarCache).forEach(([key, value]) => { - if (typeof value === 'string') { - safeAvatarCache.set(key, value); - } - }); - } catch (error) { - console.warn('Failed to reconstruct avatar cache from object:', error); - } - } - } - - return usernames - .map(username => safeAvatarCache.get(username)) - .filter((url): url is string => !!url); -}; - -/** - * Categorizes usernames based on their validation cache status - * - * @param usernames - Array of usernames to categorize - * @param validatedCache - Set of previously validated usernames - * @param invalidCache - Set of previously invalidated usernames - * @returns Object containing categorized username arrays - */ -export const categorizeUsernames = ( - usernames: string[], - validatedCache: Set, - invalidCache: Set -) => { - // Defensive programming: ensure caches are Sets - const safeValidatedCache = - validatedCache instanceof Set ? validatedCache : new Set(); - const safeInvalidCache = - invalidCache instanceof Set ? invalidCache : new Set(); - - const needValidation = usernames.filter( - u => !safeValidatedCache.has(u) && !safeInvalidCache.has(u) - ); - const alreadyValid = usernames.filter(u => safeValidatedCache.has(u)); - // Only include in alreadyInvalid if NOT in validatedCache (prioritize validated) - const alreadyInvalid = usernames.filter( - u => safeInvalidCache.has(u) && !safeValidatedCache.has(u) - ); - - return { - needValidation, - alreadyValid, - alreadyInvalid, - }; -}; - -/** - * Checks if username validation is needed for any usernames - * - * @param usernames - Array of usernames to check - * @param validatedCache - Set of previously validated usernames - * @param invalidCache - Set of previously invalidated usernames - * @returns True if any usernames need validation - */ -export const needsValidation = ( - usernames: string[], - validatedCache: Set, - invalidCache: Set -): boolean => { - const { needValidation } = categorizeUsernames( - usernames, - validatedCache, - invalidCache - ); - return needValidation.length > 0; -}; - -/** - * Gets usernames that are known to be invalid from cache - * - * @param usernames - Array of usernames to check - * @param invalidCache - Set of previously invalidated usernames - * @returns Array of usernames that are cached as invalid - */ -export const getInvalidUsernames = ( - usernames: string[], - invalidCache: Set -): string[] => { - // Defensive programming: ensure invalidCache is a Set - const safeInvalidCache = - invalidCache instanceof Set ? invalidCache : new Set(); - return usernames.filter(u => safeInvalidCache.has(u)); -}; diff --git a/src/utils/viewFiltering.ts b/src/utils/viewFiltering.ts deleted file mode 100644 index e91936b..0000000 --- a/src/utils/viewFiltering.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { GitHubItem } from '../types'; -import { parseSearchText } from './resultsUtils'; - -/** - * Advanced filtering logic for GitHub items based on labels, users, and text - * This replaces the duplicate filtering logic found in Summary.tsx and EventView.tsx - */ -export const filterItemsByAdvancedSearch = ( - items: GitHubItem[], - searchText: string -): GitHubItem[] => { - if (!searchText || !searchText.trim()) { - return items; - } - - const { includedLabels, excludedLabels, userFilters, includedRepos, excludedRepos, cleanText } = parseSearchText(searchText); - - return items.filter(item => { - // Check label filters first - if (includedLabels.length > 0 || excludedLabels.length > 0) { - const itemLabels = (item.labels || []).map(label => - label.name.toLowerCase() - ); - - // Check if item has all required included labels - if (includedLabels.length > 0) { - const hasAllIncludedLabels = includedLabels.every(labelName => - itemLabels.includes(labelName.toLowerCase()) - ); - if (!hasAllIncludedLabels) return false; - } - - // Check if item has any excluded labels - if (excludedLabels.length > 0) { - const hasExcludedLabel = excludedLabels.some(labelName => - itemLabels.includes(labelName.toLowerCase()) - ); - if (hasExcludedLabel) return false; - } - } - - // Check user filters - if (userFilters.length > 0) { - const itemUser = item.user.login.toLowerCase(); - const matchesUser = userFilters.some(userFilter => - itemUser === userFilter.toLowerCase() - ); - if (!matchesUser) return false; - } - - // Check repository filters - if (includedRepos.length > 0 || excludedRepos.length > 0) { - // Extract repository name from repository_url (format: https://api.github.com/repos/owner/repo) - const itemRepo = item.repository_url?.replace('https://api.github.com/repos/', ''); - - if (!itemRepo) { - // If no repository info, exclude if any repo filters are specified - if (includedRepos.length > 0) return false; - } else { - // Check if item has all required included repos - if (includedRepos.length > 0) { - const hasIncludedRepo = includedRepos.some(repoFilter => - itemRepo.toLowerCase() === repoFilter.toLowerCase() - ); - if (!hasIncludedRepo) return false; - } - - // Check if item has any excluded repos - if (excludedRepos.length > 0) { - const hasExcludedRepo = excludedRepos.some(repoFilter => - itemRepo.toLowerCase() === repoFilter.toLowerCase() - ); - if (hasExcludedRepo) return false; - } - } - } - - // If there's clean text remaining, search in title, body, and username - if (cleanText) { - const searchLower = cleanText.toLowerCase(); - const titleMatch = item.title.toLowerCase().includes(searchLower); - const bodyMatch = item.body?.toLowerCase().includes(searchLower); - const userMatch = item.user.login.toLowerCase().includes(searchLower); - return titleMatch || bodyMatch || userMatch; - } - - // If only label/user/repo filters were used, item passed checks above - return true; - }); -}; - -/** - * Sorts GitHub items by updated date (newest first) - * This replaces the duplicate sorting logic found across all view components - */ -export const sortItemsByUpdatedDate = (items: GitHubItem[]): GitHubItem[] => { - return [...items].sort( - (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - ); -}; - -/** - * Parses comma-separated usernames (common pattern in IssuesAndPRsList) - */ -export const parseCommaSeparatedUsernames = (username: string): string[] => { - return username.split(',').map(u => u.trim().toLowerCase()); -}; - -/** - * Checks if an item is authored by any of the searched users (common pattern) - */ -export const isItemAuthoredBySearchedUsers = (item: GitHubItem, searchedUsernames: string[]): boolean => { - const itemAuthor = item.user.login.toLowerCase(); - const searchedUsernamesLower = searchedUsernames.map(username => username.toLowerCase()); - return searchedUsernamesLower.includes(itemAuthor); -}; \ No newline at end of file diff --git a/src/views/EventView.tsx b/src/views/EventView.tsx index f6ad6be..cca2d2e 100644 --- a/src/views/EventView.tsx +++ b/src/views/EventView.tsx @@ -9,7 +9,7 @@ import { GitHubItem, GitHubEvent } from '../types'; import { ResultsContainer } from '../components/ResultsContainer'; import { useCopyFeedback } from '../hooks/useCopyFeedback'; -import { filterItemsByAdvancedSearch, sortItemsByUpdatedDate } from '../utils/viewFiltering'; +import { filterItemsByAdvancedSearch, sortItemsByUpdatedDate } from '../utils/filtering'; import { copyResultsToClipboard as copyToClipboard } from '../utils/clipboard'; import DescriptionDialog from '../components/DescriptionDialog'; diff --git a/src/views/IssuesAndPRsList.tsx b/src/views/IssuesAndPRsList.tsx index 6d01e57..453cca2 100644 --- a/src/views/IssuesAndPRsList.tsx +++ b/src/views/IssuesAndPRsList.tsx @@ -22,8 +22,7 @@ import { useCopyFeedback } from '../hooks/useCopyFeedback'; import { useFormContext } from '../App'; import { copyResultsToClipboard as copyToClipboard } from '../utils/clipboard'; -import { sortItemsByUpdatedDate } from '../utils/viewFiltering'; -import { filterItemsByAdvancedSearch } from '../utils/viewFiltering'; +import { sortItemsByUpdatedDate, filterItemsByAdvancedSearch } from '../utils/filtering'; import { ResultsContainer } from '../components/ResultsContainer'; diff --git a/src/views/Summary.tsx b/src/views/Summary.tsx index c83a2e9..3f6b299 100644 --- a/src/views/Summary.tsx +++ b/src/views/Summary.tsx @@ -16,7 +16,15 @@ import { GitHubItem, GitHubEvent } from '../types'; import { ResultsContainer } from '../components/ResultsContainer'; import { copyResultsToClipboard as copyToClipboard } from '../utils/clipboard'; import { useCopyFeedback } from '../hooks/useCopyFeedback'; -import { filterItemsByAdvancedSearch, sortItemsByUpdatedDate } from '../utils/viewFiltering'; +import { filterItemsByAdvancedSearch, sortItemsByUpdatedDate } from '../utils/filtering'; +import { + groupSummaryData, + getEventType, + formatGroupedDataForClipboard, + getAllDisplayedItems, + hasAnyItems, + getGroupSelectState, +} from '../utils/summary'; import DescriptionDialog from '../components/DescriptionDialog'; import BulkCopyButtons from '../components/BulkCopyButtons'; @@ -25,13 +33,6 @@ import EmptyState from '../components/EmptyState'; import './Summary.css'; import { useFormContext } from '../App'; import { useLocalStorage } from '../hooks/useLocalStorage'; -import { groupSummaryData, getEventType } from '../utils/summaryGrouping'; -import { - formatGroupedDataForClipboard, - getAllDisplayedItems, - hasAnyItems, - getGroupSelectState -} from '../utils/summaryHelpers'; import { DismissibleBanner } from '../components/DismissibleBanner'; From fd0de3f5e57ade038dbb25b4555206469325c109 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:55:23 +0000 Subject: [PATCH 2/3] Initial plan From 9cb06ce9641e74b4c71b0721f745d138e2a3c6fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:59:49 +0000 Subject: [PATCH 3/3] Fix build errors: update import path and add assignee types Co-authored-by: kertal <463851+kertal@users.noreply.github.com> --- src/hooks/useLocalStorage.ts | 2 +- src/types.ts | 10 ++++++++++ src/utils/githubData.ts | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 60a68ae..466750a 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -138,7 +138,7 @@ export function useFormSettings(key: string, initialValue: FormSettings, onUrlPa // Run cache cleanup in background (don't await to avoid blocking the URL processing) (async () => { try { - const { clearCachesKeepToken } = await import('../utils/storageUtils'); + const { clearCachesKeepToken } = await import('../utils/storage'); const preservedToken = await clearCachesKeepToken(); // Update the form settings to preserve the token if we have one diff --git a/src/types.ts b/src/types.ts index f7eae75..5f64cd0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,6 +101,16 @@ export interface GitHubEvent { avatar_url: string; html_url: string; }; + assignee?: { + login: string; + avatar_url: string; + html_url: string; + }; + assignees?: Array<{ + login: string; + avatar_url: string; + html_url: string; + }>; }; pull_request?: { id: number; diff --git a/src/utils/githubData.ts b/src/utils/githubData.ts index 97e28fa..b61da5a 100644 --- a/src/utils/githubData.ts +++ b/src/utils/githubData.ts @@ -50,8 +50,8 @@ export const transformEventToItem = (event: GitHubEvent): GitHubItem | null => { closed_at: issue.closed_at, number: issue.number, user: actorUser, - assignee: (payload as Record).issue?.assignee || null, - assignees: (payload as Record).issue?.assignees || [], + assignee: issue.assignee || null, + assignees: issue.assignees || [], pull_request: issue.pull_request, original: payload, originalEventType: type,