diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index 68a8a0c..5c5722d 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,15 +1,18 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import { classifyCommit } from '../utils/commitClassifier'; export const useGitHubData = (getOctokit: () => any) => { - const [issues, setIssues] = useState([]); - const [prs, setPrs] = useState([]); + const [issues, setIssues] = useState([]); + const [prs, setPrs] = useState([]); + const [commits, setCommits] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [totalIssues, setTotalIssues] = useState(0); const [totalPrs, setTotalPrs] = useState(0); + const [totalCommits, setTotalCommits] = useState(0); const [rateLimited, setRateLimited] = useState(false); - const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10) => { + const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10, signal?: AbortSignal) => { const q = `author:${username} is:${type}`; const response = await octokit.request('GET /search/issues', { q, @@ -17,6 +20,7 @@ export const useGitHubData = (getOctokit: () => any) => { order: 'desc', per_page, page, + request: signal ? { signal } : undefined, }); return { @@ -25,59 +29,137 @@ export const useGitHubData = (getOctokit: () => any) => { }; }; + const fetchCommitsPaginated = async (octokit: any, username: string, page = 1, per_page = 10, signal?: AbortSignal) => { + const q = `author:${username}`; + const response = await octokit.request('GET /search/commits', { + q, + sort: 'author-date', + order: 'desc', + per_page, + page, + headers: { + accept: 'application/vnd.github.cloak-preview+json', + }, + request: signal ? { signal } : undefined, + }); + + const items = response.data.items.map((item: any) => ({ + ...item, + created_at: item.commit.author?.date || item.commit.committer?.date, + classifiedInfo: classifyCommit(item.commit.message), + })); + + return { + items, + total: response.data.total_count, + }; + }; + + // refs for debounce timer and abort controller + const debounceRef = useRef(null); + const abortControllerRef = useRef(null); + const fetchData = useCallback( - async (username: string, page = 1, perPage = 10) => { - + (username: string, page = 1, perPage = 10) => { + const DEBOUNCE_MS = 350; + const octokit = getOctokit(); - if (!octokit || !username) return; - - setLoading(true); - setError(''); - - try { - const [issueRes, prRes] = await Promise.all([ - fetchPaginated(octokit, username, 'issue', page, perPage), - fetchPaginated(octokit, username, 'pr', page, perPage), - ]); - - setIssues(issueRes.items); - setPrs(prRes.items); - setTotalIssues(issueRes.total); - setTotalPrs(prRes.total); - setRateLimited(false); - } catch (err: any) { - const errorMessage = err.message?.toLowerCase() || ""; - if (err.status === 403) { - setError('GitHub API rate limit exceeded. Please provide a PAT to continue.'); - setRateLimited(true); - } else if (errorMessage.includes("do not exist")){ - setError('User not found. Please check the spelling of the GitHub username.'); - } else if (err.status === 401 || errorMessage.includes("permission")){ - setError('Private repository detected. Please input PAT.'); - }else if(err.status===404){ - setError('Resource not found.'); - } - else if (errorMessage.includes("validation failed")) { - setError('Invalid GitHub username or insufficient permissions.'); + if (!octokit) return; + + // debounce: clear existing timer + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = window.setTimeout(async () => { + // cancel previous in-flight requests + if (abortControllerRef.current) { + try { + abortControllerRef.current.abort(); + } catch (e) { + // ignore + } } - else { - setError( - 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' - ); + + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(''); + + try { + const [issueRes, prRes, commitRes] = await Promise.all([ + fetchPaginated(octokit, username, 'issue', page, perPage, controller.signal), + fetchPaginated(octokit, username, 'pr', page, perPage, controller.signal), + fetchCommitsPaginated(octokit, username, page, perPage, controller.signal).catch((err) => { + if (err.name === 'AbortError') return { items: [], total: 0 }; + console.error('Commit fetch failed:', err); + return { items: [], total: 0 }; + }), + ]); + + setIssues(issueRes.items); + setPrs(prRes.items); + setCommits(commitRes.items); + setTotalIssues(issueRes.total); + setTotalPrs(prRes.total); + setTotalCommits(commitRes.total); + setRateLimited(false); + } catch (err: any) { + if (err.name === 'AbortError') { + return; + } + const errorMessage = err.message?.toLowerCase() || ""; + if (err.status === 403) { + setError('GitHub API rate limit exceeded. Please provide a PAT to continue.'); + setRateLimited(true); + } else if (errorMessage.includes("do not exist")){ + setError('User not found. Please check the spelling of the GitHub username.'); + } else if (err.status === 401 || errorMessage.includes("permission")){ + setError('Private repository detected. Please input PAT.'); + } else if(err.status===404){ + setError('Resource not found.'); + } else if (errorMessage.includes("validation failed")) { + setError('Invalid GitHub username or insufficient permissions.'); + } else { + setError( + 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' + ); + } + } finally { + setLoading(false); + if (abortControllerRef.current === controller) abortControllerRef.current = null; } - } finally { - setLoading(false); - } + }, DEBOUNCE_MS); }, [getOctokit] ); + // cleanup on unmount: clear debounce timer and abort any in-flight requests + useEffect(() => { + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + if (abortControllerRef.current) { + try { + abortControllerRef.current.abort(); + } catch (e) { + // ignore + } + abortControllerRef.current = null; + } + }; + }, []); + return { issues, prs, + commits, totalIssues, totalPrs, + totalCommits, loading, error, fetchData, diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index ce4116f..14e1d58 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -5,6 +5,7 @@ import { GitPullRequestIcon, GitPullRequestClosedIcon, GitMergeIcon, + GitCommitIcon, } from '@primer/octicons-react'; import { Container, @@ -44,6 +45,17 @@ interface GitHubItem { pull_request?: { merged_at: string | null }; repository_url: string; html_url: string; + commit?: { + message: string; + }; + repository?: { + html_url: string; + }; + classifiedInfo?: { + importance: string; + category: string; + score: number; + }; } const Home: React.FC = () => { @@ -62,8 +74,10 @@ const Home: React.FC = () => { const { issues, prs, + commits, totalIssues, totalPrs, + totalCommits, loading, error: dataError, fetchData, @@ -74,6 +88,7 @@ const Home: React.FC = () => { const [issueFilter, setIssueFilter] = useState("all"); const [prFilter, setPrFilter] = useState("all"); + const [commitFilter, setCommitFilter] = useState("all"); const [searchTitle, setSearchTitle] = useState(""); const [selectedRepo, setSelectedRepo] = useState(""); const [startDate, setStartDate] = useState(""); @@ -96,8 +111,11 @@ const Home: React.FC = () => { setPage(newPage); }; - const formatDate = (dateString: string): string => - new Date(dateString).toLocaleDateString(); + const formatDate = (dateString: string): string => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return isNaN(date.getTime()) ? 'N/A' : date.toLocaleDateString(); + }; const filterData = (data: GitHubItem[], filterType: string): GitHubItem[] => { let filtered = [...data]; @@ -115,15 +133,20 @@ const Home: React.FC = () => { } }); } + if (["High", "Medium", "Low"].includes(filterType)) { + filtered = filtered.filter(item => item.classifiedInfo?.importance === filterType); + } if (searchTitle) { - filtered = filtered.filter((item) => - item.title.toLowerCase().includes(searchTitle.toLowerCase()) - ); + filtered = filtered.filter((item) => { + const title = item.commit ? item.commit.message : item.title; + return title.toLowerCase().includes(searchTitle.toLowerCase()); + }); } if (selectedRepo) { - filtered = filtered.filter((item) => - item.repository_url.includes(selectedRepo) - ); + filtered = filtered.filter((item) => { + const repoUrl = item.repository?.html_url || item.repository_url; + return (repoUrl || '').includes(selectedRepo); + }); } if (startDate) { filtered = filtered.filter( @@ -140,6 +163,10 @@ const Home: React.FC = () => { const getStatusIcon = (item: GitHubItem) => { + if (item.commit) { + return ; + } + if (item.pull_request) { if (item.pull_request.merged_at) @@ -159,9 +186,10 @@ const Home: React.FC = () => { // Current data and filtered data according to tab and filters - const currentRawData = tab === 0 ? issues : prs; - const currentFilteredData = filterData(currentRawData, tab === 0 ? issueFilter : prFilter); - const totalCount = tab === 0 ? totalIssues : totalPrs; + const currentRawData = tab === 0 ? issues : (tab === 1 ? prs : commits); + const currentFilter = tab === 0 ? issueFilter : (tab === 1 ? prFilter : commitFilter); + const currentFilteredData = filterData(currentRawData, currentFilter); + const totalCount = tab === 0 ? totalIssues : (tab === 1 ? totalPrs : totalCommits); return ( @@ -262,17 +290,18 @@ const Home: React.FC = () => { > + - State + {tab === 2 ? 'Importance' : 'State'} @@ -361,9 +403,9 @@ const Home: React.FC = () => { - Title + Title / Message Repository - State + Status / Importance Created @@ -374,24 +416,43 @@ const Home: React.FC = () => { {getStatusIcon(item)} - - {item.title} - + + + {item.commit ? item.commit.message.split('\n')[0] : item.title} + + {item.classifiedInfo && ( + + + {item.classifiedInfo.category} + + + )} + - {item.repository_url.split("/").slice(-1)[0]} + {(item.repository?.html_url || item.repository_url || "").split("/").slice(-1)[0]} - {item.pull_request?.merged_at ? "merged" : item.state} + {item.commit ? ( + + {item.classifiedInfo?.importance} + + ) : ( + item.pull_request?.merged_at ? "merged" : item.state + )} {formatDate(item.created_at)} diff --git a/src/utils/commitClassifier.ts b/src/utils/commitClassifier.ts new file mode 100644 index 0000000..7950e25 --- /dev/null +++ b/src/utils/commitClassifier.ts @@ -0,0 +1,70 @@ +export type CommitImportance = 'High' | 'Medium' | 'Low' | 'Unknown'; +export type CommitCategory = 'Feature' | 'Bugfix' | 'Refactor' | 'Chore' | 'Docs' | 'Test' | 'Unknown'; + +export interface ClassifiedCommit { + importance: CommitImportance; + category: CommitCategory; + score: number; +} + +export function classifyCommit( + message: string, + filesChanged: number = 0, + additions: number = 0, + deletions: number = 0 +): ClassifiedCommit { + const lowerMsg = message.toLowerCase(); + + let category: CommitCategory = 'Unknown'; + if (/feat\b|feature\b|add\b|implement\b|create\b/.test(lowerMsg)) { + category = 'Feature'; + } else if (/fix\b|bug\b|patch\b|resolve\b/.test(lowerMsg)) { + category = 'Bugfix'; + } else if (/refactor\b|clean\b|rework\b/.test(lowerMsg)) { + category = 'Refactor'; + } else if (/doc\b|readme\b|comment\b/.test(lowerMsg)) { + category = 'Docs'; + } else if (/test\b|mock\b|spec\b/.test(lowerMsg)) { + category = 'Test'; + } else if (/chore\b|bump\b|update\b|depend\b|config\b|format\b|style\b/.test(lowerMsg)) { + category = 'Chore'; + } + + let score = 0; + + // Base score from category + if (category === 'Feature') score += 5; + if (category === 'Bugfix') score += 4; + if (category === 'Refactor') score += 3; + if (category === 'Test') score += 2; + if (category === 'Docs') score += 1; + if (category === 'Chore') score += 1; + + // Impact from size (if available, e.g., if fetched specifically, but search API might omit it, defaulting to 0) + const totalChanges = additions + deletions; + if (totalChanges > 500) score += 3; + else if (totalChanges > 100) score += 2; + else if (totalChanges > 20) score += 1; + + if (filesChanged > 10) score += 2; + else if (filesChanged > 3) score += 1; + + // Keyword modifiers + if (/wip\b|temp\b|typo\b|minor\b|init\b/.test(lowerMsg)) { + score -= 2; + } + if (/major\b|breaking\b|critical\b|core\b/.test(lowerMsg)) { + score += 3; + } + + let importance: CommitImportance = 'Medium'; + if (category === 'Unknown' && score === 0) { + importance = 'Unknown'; + } else if (score >= 6) { + importance = 'High'; + } else if (score <= 2) { + importance = 'Low'; + } + + return { importance, category, score }; +}