diff --git a/frontend/src/MuiApp.tsx b/frontend/src/MuiApp.tsx index 7de5338..0676561 100644 --- a/frontend/src/MuiApp.tsx +++ b/frontend/src/MuiApp.tsx @@ -18,6 +18,7 @@ import { ContributorsSection, IssuesSection, CommitsSection, + BranchesSection, } from './components'; function App() { @@ -179,6 +180,12 @@ function App() { repo={repo} /> + + {/* Footer */} = ({ + branches, + owner, + repo, +}) => { + const [sortBy, setSortBy] = useState('last_commit_date'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // Sort branches based on current sort criteria + const sortedBranches = useMemo(() => { + return [...branches].sort((a, b) => { + let aValue: any = a[sortBy]; + let bValue: any = b[sortBy]; + + // Handle special sorting cases + if (sortBy === 'last_commit_date') { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } else if (sortBy === 'ahead_by' || sortBy === 'behind_by') { + aValue = aValue ?? 0; + bValue = bValue ?? 0; + } else if (sortBy === 'contributors') { + aValue = a.contributors.length; + bValue = b.contributors.length; + } + + if (aValue < bValue) { + return sortDirection === 'asc' ? -1 : 1; + } + if (aValue > bValue) { + return sortDirection === 'asc' ? 1 : -1; + } + return 0; + }); + }, [branches, sortBy, sortDirection]); + + const handleSort = (column: keyof Branch) => { + if (sortBy === column) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(column); + setSortDirection('asc'); + } + }; + + return ( + + + + + + Branches + + + {branches.length} active branches in this repository + + + + + {branches && branches.length > 0 ? ( + + + + + handleSort('name')} + > + Branch Name + + + + handleSort('contributors')} + > + Contributors + + + + handleSort('last_commit_date')} + > + Last Updated + + + Last Commit + + handleSort('ahead_by')} + > + Ahead + + + + handleSort('behind_by')} + > + Behind + + + + + + {sortedBranches.map((branch) => ( + + + + {branch.name} + + + + + + {branch.contributors.map((contributor) => ( + + {contributor[0].toUpperCase()} + + ))} + + {branch.contributors.length > 5 && ( + + +{branch.contributors.length - 5} + + )} + + + {formatDateTime(branch.last_commit_date)} + + + {branch.last_commit_sha.slice(0, 7)} + + + + {branch.ahead_by !== undefined ? ( + 0 ? 'success' : 'default'} + sx={{ fontWeight: 600 }} + /> + ) : ( + '—' + )} + + + {branch.behind_by !== undefined ? ( + 0 ? 'warning' : 'default'} + sx={{ fontWeight: 600 }} + /> + ) : ( + '—' + )} + + + ))} + + + ) : ( + + No branches found + + )} + + + ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6106cde..2e0686a 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -4,3 +4,4 @@ export { PullRequestsSection } from './PullRequestsSection'; export { ContributorsSection } from './ContributorsSection'; export { IssuesSection } from './IssuesSection'; export { CommitsSection } from './CommitsSection'; +export { BranchesSection } from './BranchesSection'; diff --git a/frontend/src/data/mockData.ts b/frontend/src/data/mockData.ts index a64e9c1..5e42507 100644 --- a/frontend/src/data/mockData.ts +++ b/frontend/src/data/mockData.ts @@ -17,26 +17,31 @@ export const mockAnalysisData: AnalysisReport = { { login: 'alice', commits: 14, + commits_all_branches: 18, prs: 3, }, { login: 'bob', commits: 8, + commits_all_branches: 12, prs: 4, }, { login: 'carol', commits: 12, + commits_all_branches: 15, prs: 2, }, { login: 'dave', commits: 6, + commits_all_branches: 9, prs: 1, }, { login: 'eve', commits: 10, + commits_all_branches: 13, prs: 2, }, ], @@ -182,4 +187,49 @@ export const mockAnalysisData: AnalysisReport = { deletions: 112, }, ], + branches: [ + { + name: 'main', + last_commit_sha: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t', + last_commit_date: '2025-09-26T18:20:00Z', + contributors: ['alice', 'bob', 'carol', 'dave', 'eve'], + url: 'https://github.com/cmu-sei/example-project/tree/main', + }, + { + name: 'feature/user-authentication', + last_commit_sha: 'f1e2d3c4b5a6978655443322110fedcba9876543', + last_commit_date: '2025-09-25T10:15:00Z', + contributors: ['alice', 'bob'], + ahead_by: 5, + behind_by: 2, + url: 'https://github.com/cmu-sei/example-project/tree/feature/user-authentication', + }, + { + name: 'feature/dashboard-redesign', + last_commit_sha: 'd4c3b2a1098765432100ffeeddccbbaa99887766', + last_commit_date: '2025-09-24T14:30:00Z', + contributors: ['carol'], + ahead_by: 12, + behind_by: 8, + url: 'https://github.com/cmu-sei/example-project/tree/feature/dashboard-redesign', + }, + { + name: 'fix/database-connection', + last_commit_sha: '9988776655443322110ffeeddccbbaa11223344', + last_commit_date: '2025-09-26T09:45:00Z', + contributors: ['eve', 'dave'], + ahead_by: 3, + behind_by: 1, + url: 'https://github.com/cmu-sei/example-project/tree/fix/database-connection', + }, + { + name: 'develop', + last_commit_sha: 'aabbccddee00112233445566778899ffeeddccbb', + last_commit_date: '2025-09-26T16:00:00Z', + contributors: ['alice', 'bob', 'carol', 'dave'], + ahead_by: 8, + behind_by: 0, + url: 'https://github.com/cmu-sei/example-project/tree/develop', + }, + ], }; diff --git a/shared/types/index.ts b/shared/types/index.ts index b62a5bf..7c06cf3 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -23,6 +23,7 @@ export interface AnalysisReport { pull_requests: PullRequest[]; commits?: Commit[]; issues: Issue[]; + branches?: Branch[]; } export interface Contributor { @@ -76,3 +77,13 @@ export interface Commit { additions?: number; deletions?: number; } + +export interface Branch { + name: string; + last_commit_sha: string; + last_commit_date: string; + contributors: string[]; + ahead_by?: number; + behind_by?: number; + url: string; +} diff --git a/src/services/commit-culture.ts b/src/services/commit-culture.ts index e70d687..2cbfd1d 100644 --- a/src/services/commit-culture.ts +++ b/src/services/commit-culture.ts @@ -26,12 +26,13 @@ export class CommitCultureService { from: Date, to: Date ): Promise { - const [pullRequests, commits, issues, allBranchesCommitCounts] = + const [pullRequests, commits, issues, allBranchesCommitCounts, branches] = await Promise.all([ this.github.getPullRequestsSince(owner, repo, from, to), this.github.fetchCommitsForDefaultBranch(owner, repo, from, to), this.github.getIssuesSince(owner, repo, from, to), this.github.getCommitCountsAcrossBranches(owner, repo, from, to), + this.github.fetchBranches(owner, repo, from, to), ]); // Aggregate contributor data @@ -61,6 +62,7 @@ export class CommitCultureService { pull_requests: pullRequests, commits: commits, issues, + branches, }; } diff --git a/src/services/github.ts b/src/services/github.ts index b5bddcd..40972bd 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,6 +1,5 @@ import { Octokit } from '@octokit/rest'; -import type { PullRequest } from '../types/index.js'; -import type { Commit } from '../types/index.js'; +import type { PullRequest, Branch, Commit } from '../types/index.js'; export class GitHubService { private octokit: Octokit; @@ -632,6 +631,211 @@ export class GitHubService { return commitCounts; } + /** + * Fetch branch information including contributors and commit details + * Uses only GraphQL queries (no REST API calls) + */ + async fetchBranches( + owner: string, + repo: string, + since: Date, + until?: Date + ): Promise { + console.log(`🌿 Fetching branches for ${owner}/${repo} (graphql)...`); + const branches: Branch[] = []; + const sinceIso = since.toISOString(); + const untilIso = until ? until.toISOString() : new Date().toISOString(); + + try { + // First, get repository info and default branch name + const repoQuery = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + } + } + `; + + const repoResult: any = await this.octokit.graphql(repoQuery, { + owner, + repo, + }); + + const defaultBranch = repoResult.repository?.defaultBranchRef?.name; + if (!defaultBranch) { + console.warn('⚠️ Could not determine default branch'); + return []; + } + + // Get all branches with pagination + const branchesQuery = ` + query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + refs(first: 100, refPrefix: "refs/heads/", after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + name + } + } + } + } + `; + + const branchNames: string[] = []; + let hasNext = true; + let after: string | null = null; + + while (hasNext) { + const result: any = await this.octokit.graphql(branchesQuery, { + owner, + repo, + after, + }); + + const refsData = result.repository?.refs; + if (!refsData) break; + + const nodes = refsData.nodes || []; + for (const node of nodes) { + branchNames.push(node.name); + } + + hasNext = refsData.pageInfo?.hasNextPage || false; + after = refsData.pageInfo?.endCursor || null; + } + + console.log(`📊 Found ${branchNames.length} branches`); + + // For each branch, get detailed information + for (const branchName of branchNames) { + try { + const branchQuery = ` + query($owner: String!, $repo: String!, $branch: String!, $since: GitTimestamp!, $until: GitTimestamp!) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $branch) { + target { + ... on Commit { + oid + committedDate + history(first: 100, since: $since, until: $until) { + nodes { + oid + author { + name + email + user { + login + } + } + } + } + } + } + } + } + } + `; + + const branchResult: any = await this.octokit.graphql(branchQuery, { + owner, + repo, + branch: `refs/heads/${branchName}`, + since: sinceIso, + until: untilIso, + }); + + const target = branchResult.repository?.ref?.target; + const history = target?.history; + const contributorsSet = new Set(); + + if (history && history.nodes) { + for (const node of history.nodes) { + const login = node.author?.user?.login || node.author?.name; + if (login) { + contributorsSet.add(login); + } + } + } + + // Get last commit info + const lastCommitSha = target?.oid || ''; + const lastCommitDate = + target?.committedDate || new Date().toISOString(); + + // Calculate ahead/behind compared to default branch using GraphQL + let aheadBy: number | undefined; + let behindBy: number | undefined; + + if (branchName !== defaultBranch) { + try { + const compareQuery = ` + query($owner: String!, $repo: String!, $base: String!, $head: String!) { + repository(owner: $owner, name: $repo) { + base: ref(qualifiedName: $base) { + compare(headRef: $head) { + aheadBy + behindBy + } + } + } + } + `; + + const compareResult: any = await this.octokit.graphql( + compareQuery, + { + owner, + repo, + base: `refs/heads/${defaultBranch}`, + head: `refs/heads/${branchName}`, + } + ); + + const comparison = compareResult.repository?.base?.compare; + aheadBy = comparison?.aheadBy; + behindBy = comparison?.behindBy; + } catch (error: any) { + console.warn( + ` ⚠️ Could not compare branch ${branchName} with ${defaultBranch}:`, + error?.message || error + ); + } + } + + branches.push({ + name: branchName, + last_commit_sha: lastCommitSha, + last_commit_date: lastCommitDate, + contributors: Array.from(contributorsSet), + ahead_by: aheadBy, + behind_by: behindBy, + url: `https://github.com/${owner}/${repo}/tree/${branchName}`, + }); + + console.log( + ` ✅ Fetched branch: ${branchName} (${contributorsSet.size} contributors)` + ); + } catch (error: any) { + console.warn( + ` ⚠️ Failed to fetch details for branch ${branchName}:`, + error?.message || error + ); + } + } + + console.log(`🎉 Completed fetching ${branches.length} branches`); + return branches; + } catch (error: any) { + console.error('❌ Failed to fetch branches:', error?.message || error); + return []; + } + } + /** * Get all refs (branches) for a repository */