diff --git a/package.json b/package.json index 1c20652..44b9ebf 100644 --- a/package.json +++ b/package.json @@ -37,38 +37,38 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fastify/cors": "^8.5.0", - "@mui/icons-material": "^7.3.2", - "@mui/material": "^7.3.2", - "@mui/x-data-grid": "^8.12.1", - "@octokit/graphql": "^9.0.1", - "@octokit/rest": "^22.0.0", - "@types/react": "^19.1.14", - "@types/react-dom": "^19.1.9", - "@vitejs/plugin-react": "^5.0.3", + "@fastify/cors": "^11.2.0", + "@mui/icons-material": "^7.3.8", + "@mui/material": "^7.3.8", + "@mui/x-data-grid": "^8.27.1", + "@octokit/graphql": "^9.0.3", + "@octokit/rest": "^22.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", "bottleneck": "^2.19.5", - "dotenv": "^17.2.2", - "fastify": "^4.28.1", - "lucide-react": "^0.544.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "recharts": "^3.2.1", - "vite": "^5.4.20" + "dotenv": "^17.3.1", + "fastify": "^5.7.4", + "lucide-react": "^0.575.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "recharts": "^3.7.0", + "vite": "^7.3.1" }, "devDependencies": { - "@eslint/js": "^9.36.0", - "@types/node": "^22.7.7", - "@typescript-eslint/eslint-plugin": "^8.44.1", - "@typescript-eslint/parser": "^8.44.1", - "@vitest/coverage-v8": "^2.0.0", + "@eslint/js": "^10.0.1", + "@types/node": "^25.3.0", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vitest/coverage-v8": "^4.0.18", "concurrently": "^9.2.1", - "eslint": "^8.57.1", + "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "pino-pretty": "^13.1.1", - "prettier": "^3.6.2", - "tsx": "^4.16.2", - "typescript": "^5.6.2", - "vitest": "^2.0.0" + "eslint-plugin-prettier": "^5.5.5", + "pino-pretty": "^13.1.3", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" } } diff --git a/src/services/commit-culture.ts b/src/services/commit-culture.ts index 2cbfd1d..b251b34 100644 --- a/src/services/commit-culture.ts +++ b/src/services/commit-culture.ts @@ -26,14 +26,14 @@ export class CommitCultureService { from: Date, to: Date ): Promise { - 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), - ]); + const [pullRequests, commits, issues, branchResult] = 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.fetchBranchesAndCommitCounts(owner, repo, from, to), + ]); + + const { branches, commitCounts: allBranchesCommitCounts } = branchResult; // Aggregate contributor data const contributors = this.aggregateContributors( diff --git a/src/services/github.ts b/src/services/github.ts index 40972bd..feb6074 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -19,6 +19,46 @@ export class GitHubService { this.abortController.abort(); } + /** + * Parse CI status from a checkSuites GraphQL response node. + * Returns a normalized status based on the check run conclusions and statuses. + */ + private parseCIStatus( + checkSuites: any + ): 'success' | 'failure' | 'pending' | 'unknown' | 'none' { + const checkRunNodes: any[] = []; + const suites = checkSuites?.nodes || []; + for (const suite of suites) { + const runs = suite.checkRuns?.nodes || []; + for (const r of runs) checkRunNodes.push(r); + } + + if (checkRunNodes.length === 0) return 'none'; + + const hasFailure = checkRunNodes.some( + (run) => + (run.conclusion || '').toString().toLowerCase() === 'failure' || + (run.conclusion || '').toString().toLowerCase() === 'cancelled' + ); + const hasSuccess = checkRunNodes.some( + (run) => (run.conclusion || '').toString().toLowerCase() === 'success' + ); + const hasPending = checkRunNodes.some((run) => { + const s = (run.status || '').toString().toLowerCase(); + return ( + s === 'in_progress' || + s === 'queued' || + s === 'waiting' || + (run.conclusion || '').toString().toLowerCase() === 'neutral' + ); + }); + + if (hasPending) return 'pending'; + if (hasFailure) return 'failure'; + if (hasSuccess) return 'success'; + return 'unknown'; + } + async getPullRequestsSince( owner: string, repo: string, @@ -26,40 +66,79 @@ export class GitHubService { until?: Date ): Promise { const pullRequests: PullRequest[] = []; - let page = 1; const perPage = 100; - let totalProcessed = 0; + let after: string | null = null; + + console.log(`🔍 Fetching pull requests from ${owner}/${repo} (graphql)...`); - console.log(`🔍 Fetching pull requests from ${owner}/${repo}...`); + const query = ` + query($owner: String!, $repo: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequests( + first: $first, + after: $after, + orderBy: {field: CREATED_AT, direction: DESC} + ) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + state + isDraft + url + author { login } + createdAt + mergedAt + closedAt + additions + deletions + reviews(first: 100) { + totalCount + nodes { author { login } } + } + comments: comments(first: 1) { totalCount } + closingIssuesReferences(first: 1) { + nodes { number } + } + lastCommit: commits(last: 1) { + nodes { + commit { + oid + checkSuites(first: 50) { + nodes { + checkRuns(first: 50) { + nodes { name conclusion status } + } + } + } + } + } + } + } + } + } + } + `; let hasMore = true; while (hasMore) { - console.log( - `📄 Fetching page ${page} (up to ${perPage} PRs per page)...` - ); + console.log(`📄 Fetching PR page (up to ${perPage} PRs per page)...`); - const response = await this.octokit.pulls.list({ + const result: any = await this.octokit.graphql(query, { owner, repo, - state: 'all', - sort: 'created', - direction: 'desc', - per_page: perPage, - page, + first: perPage, + after, }); - if (response.data.length === 0) { - console.log('📄 No more pull requests found'); - hasMore = false; - break; - } + const connection = result.repository?.pullRequests; + if (!connection) break; - console.log( - `📋 Processing ${response.data.length} pull requests from page ${page}...` - ); + const nodes = connection.nodes || []; + console.log(`📋 Processing ${nodes.length} pull requests...`); - for (const pr of response.data) { - const createdAt = new Date(pr.created_at); + for (const pr of nodes) { + const createdAt = new Date(pr.createdAt); // Stop if we've gone past our date range if (createdAt < since) { @@ -70,28 +149,50 @@ export class GitHubService { } // Skip if it's after our until date (if specified) - if (until && createdAt > until) { - continue; - } + if (until && createdAt > until) continue; - totalProcessed++; - console.log( - `🔄 Analyzing PR #${pr.number}: "${pr.title}" (${totalProcessed} processed so far)` + const ciRaw = this.parseCIStatus( + pr.lastCommit?.nodes?.[0]?.commit?.checkSuites ); - - const analysis = await this.analyzePullRequest(owner, repo, pr.number); - pullRequests.push(analysis); - } - - // If we got fewer results than requested, we're on the last page - if (response.data.length < perPage) { - console.log(`📄 Reached final page (page ${page})`); - hasMore = false; - break; + const ciStatus: 'success' | 'failure' | 'pending' | 'unknown' | 'none' = + ciRaw === 'none' ? 'unknown' : ciRaw; + + const status: 'Open' | 'Closed' | 'Merged' | 'Draft' = (() => { + if (pr.isDraft) return 'Draft'; + if (pr.mergedAt) return 'Merged'; + if (String(pr.state || '').toLowerCase() === 'closed') + return 'Closed'; + return 'Open'; + })(); + + pullRequests.push({ + number: pr.number, + title: pr.title, + author: pr.author?.login || 'unknown', + created_at: pr.createdAt, + merged_at: pr.mergedAt || undefined, + closed_at: pr.closedAt || undefined, + status, + linked_issues: pr.closingIssuesReferences?.nodes?.[0]?.number + ? [pr.closingIssuesReferences.nodes[0].number] + : [], + additions: pr.additions || 0, + deletions: pr.deletions || 0, + reviewers: Array.from( + new Set( + ((pr.reviews?.nodes || []) as any[]) + .map((r: any) => r.author?.login) + .filter(Boolean) as string[] + ) + ), + ci_status: ciStatus, + url: pr.url, + comments: pr.comments?.totalCount || 0, + }); } - console.log(`📄 Completed page ${page}, moving to next page...`); - page++; + hasMore = Boolean(connection.pageInfo?.hasNextPage); + after = connection.pageInfo?.endCursor || null; } console.log( @@ -100,153 +201,12 @@ export class GitHubService { return pullRequests; } - async analyzePullRequest( - owner: string, - repo: string, - prNumber: number - ): Promise { - console.log(` 📊 Fetching detailed data for PR #${prNumber} (graphql)...`); - const query = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - number - title - author { login } - createdAt - mergedAt - closedAt - state - additions - deletions - changedFiles - commits { totalCount } - lastCommit: commits(last: 1) { - nodes { - commit { - oid - checkSuites(first: 50) { - nodes { - checkRuns(first: 50) { - nodes { - name - conclusion - status - } - } - } - } - } - } - } - reviews(first: 100) { - totalCount - nodes { - author { login } - } - } - comments: comments(first: 1) { totalCount } - closingIssuesReferences(first: 1) { - nodes { number } - } - url: url - } - } - } - `; - - const result: any = await this.octokit.graphql(query, { - owner, - repo, - number: prNumber, - }); - - console.log(` ✅ Completed analysis for PR #${prNumber} (graphql)`); - - const pr = result.repository.pullRequest; - const totalAdditions = pr.additions || 0; - const totalDeletions = pr.deletions || 0; - const linkedIssue = pr.closingIssuesReferences?.nodes?.[0]?.number || null; - - // FIXME this code is horrible! it needs to be cleaned up - // Determine CI status from check runs returned for the head commit (if any) - let ciStatus: 'success' | 'failure' | 'pending' | 'unknown' = 'unknown'; - try { - const checkRunNodes: any[] = []; - const commitNodes = - pr.lastCommit && pr.lastCommit.nodes ? pr.lastCommit.nodes : []; - if (commitNodes.length > 0 && commitNodes[0].commit) { - const suites = commitNodes[0].commit.checkSuites?.nodes || []; - for (const suite of suites) { - const runs = suite.checkRuns?.nodes || []; - for (const r of runs) checkRunNodes.push(r); - } - } - - if (checkRunNodes.length > 0) { - const hasFailure = checkRunNodes.some( - (run) => - (run.conclusion || '').toString().toLowerCase() === 'failure' || - (run.conclusion || '').toString().toLowerCase() === 'cancelled' - ); - const hasSuccess = checkRunNodes.some( - (run) => (run.conclusion || '').toString().toLowerCase() === 'success' - ); - const hasPending = checkRunNodes.some((run) => { - const s = (run.status || '').toString().toLowerCase(); - return ( - s === 'in_progress' || - s === 'queued' || - (run.conclusion || '').toString().toLowerCase() === 'neutral' || - s === 'waiting' - ); - }); - - if (hasPending) ciStatus = 'pending'; - else if (hasFailure) ciStatus = 'failure'; - else if (hasSuccess) ciStatus = 'success'; - else ciStatus = 'unknown'; - } else { - ciStatus = 'unknown'; - } - } catch (error) { - console.warn(`Failed to determine CI status for PR ${prNumber}:`, error); - ciStatus = 'unknown'; - } - - const status: 'Open' | 'Closed' | 'Merged' | 'Draft' = (() => { - if (pr.mergedAt) return 'Merged'; - if (String(pr.state || '').toLowerCase() === 'closed') return 'Closed'; - return 'Open'; - })(); - - return { - number: pr.number, - title: pr.title, - author: pr.author?.login || 'unknown', - created_at: pr.createdAt, - merged_at: pr.mergedAt || undefined, - closed_at: pr.closedAt || undefined, - status, - linked_issues: linkedIssue ? [linkedIssue] : [], - additions: totalAdditions, - deletions: totalDeletions, - reviewers: Array.from( - new Set( - ((pr.reviews?.nodes || []) as any[]) - .map((r: any) => r.author?.login) - .filter(Boolean) as string[] - ) - ), - ci_status: ciStatus, - url: pr.url || pr.html_url, - comments: pr.comments?.totalCount || 0, - }; - } - async validateRepository(owner: string, repo: string): Promise { try { - await this.octokit.repos.get({ owner, repo }); + await this.octokit.graphql( + `query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id } }`, + { owner, repo } + ); return true; } catch (error) { const enhancedError = new Error( @@ -453,50 +413,9 @@ export class GitHubService { node.author?.user?.login || node.author?.name || 'unknown'; // Determine CI status from check runs in the commit's check suites - let ciStatus: 'pass' | 'fail' | 'pending' | 'unknown' | 'none' = - 'unknown'; - try { - const checkRunNodes: any[] = []; - const suites = node.checkSuites?.nodes || []; - for (const suite of suites) { - const runs = suite.checkRuns?.nodes || []; - for (const r of runs) checkRunNodes.push(r); - } - - if (checkRunNodes.length > 0) { - const hasFailure = checkRunNodes.some( - (run) => - (run.conclusion || '').toString().toLowerCase() === 'failure' || - (run.conclusion || '').toString().toLowerCase() === 'cancelled' - ); - const hasSuccess = checkRunNodes.some( - (run) => - (run.conclusion || '').toString().toLowerCase() === 'success' - ); - const hasPending = checkRunNodes.some((run) => { - const s = (run.status || '').toString().toLowerCase(); - return ( - s === 'in_progress' || - s === 'queued' || - s === 'waiting' || - (run.conclusion || '').toString().toLowerCase() === 'neutral' - ); - }); - - if (hasPending) ciStatus = 'pending'; - else if (hasFailure) ciStatus = 'fail'; - else if (hasSuccess) ciStatus = 'pass'; - else ciStatus = 'unknown'; - } else { - ciStatus = 'none'; - } - } catch (error) { - console.warn( - `Failed to compute CI status for commit ${sha.slice(0, 7)}:`, - error - ); - ciStatus = 'unknown'; - } + const ciRaw = this.parseCIStatus(node.checkSuites); + const ciStatus: 'pass' | 'fail' | 'pending' | 'unknown' | 'none' = + ciRaw === 'success' ? 'pass' : ciRaw === 'failure' ? 'fail' : ciRaw; // Find associated PR (if any) const associatedPR = node.associatedPullRequests?.nodes?.[0]; @@ -525,371 +444,175 @@ export class GitHubService { } /** - * Get commit counts for all contributors across ALL branches within a date range. - * Uses GraphQL to query all refs (branches) and aggregate unique commits. - * Returns a map of login -> commit count. + * Fetch branch details and aggregate commit counts across all branches in a single pass. + * Replaces the separate fetchBranches and getCommitCountsAcrossBranches methods. + * Returns branch info for the UI and a per-contributor commit count map (deduplicated by SHA). */ - async getCommitCountsAcrossBranches( + async fetchBranchesAndCommitCounts( owner: string, repo: string, since: Date, until?: Date - ): Promise> { + ): Promise<{ branches: Branch[]; commitCounts: Map }> { console.log( - `🔍 Fetching commit counts across all branches for ${owner}/${repo}...` + `🌿 Fetching branches and commit counts for ${owner}/${repo} (graphql)...` ); + const branches: Branch[] = []; const commitCounts = new Map(); - const seenShas = new Set(); // Track unique commits across all branches - - // Step 1: Get all refs (branches and tags) - console.log(` 📋 Fetching repository refs...`); - const refs = await this.getRepositoryRefs(owner, repo); - console.log(` ✅ Found ${refs.length} refs to query`); - - // Step 2: For each ref, query commit history - const sinceIso = since.toISOString(); - const untilIso = until?.toISOString(); - - for (const ref of refs) { - console.log(` � Querying commits for ref: ${ref.name}`); - - const query = ` - query($owner: String!, $repo: String!, $refName: String!, $since: GitTimestamp, $until: GitTimestamp) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $refName) { - target { - ... on Commit { - history(first: 100, since: $since, until: $until) { - pageInfo { - hasNextPage - endCursor - } - nodes { - oid - author { - name - email - user { - login - } - } - } - } - } - } - } - } - } - `; - - try { - const result: any = await this.octokit.graphql(query, { - owner, - repo, - refName: ref.name, - since: sinceIso, - until: untilIso, - }); - - const history = result.repository?.ref?.target?.history; - if (!history) continue; - - const nodes = history.nodes || []; - console.log(` ✅ Found ${nodes.length} commits on ${ref.name}`); - - for (const node of nodes) { - const sha = node.oid; - - // Skip if we've already counted this commit - if (seenShas.has(sha)) continue; - seenShas.add(sha); - - // Get author login - const login = - node.author?.user?.login || node.author?.name || 'unknown'; - - // Skip bots - if (login.endsWith('[bot]')) continue; + const seenShas = new Set(); - commitCounts.set(login, (commitCounts.get(login) || 0) + 1); - } - - // TODO: Handle pagination if needed (hasNextPage) - // For now, we're limiting to 100 commits per branch - } catch (error: any) { - console.warn( - ` ⚠️ Failed to query ref ${ref.name}:`, - error?.message || error - ); - } - } - - console.log( - `🎉 Found ${commitCounts.size} contributors with ${seenShas.size} unique commits across all branches` - ); - 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 - } + // Step 1: Get default branch name + all branch names in one paginated query + const listQuery = ` + query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { name } + refs(first: 100, refPrefix: "refs/heads/", after: $after) { + pageInfo { hasNextPage endCursor } + nodes { 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; + let defaultBranch = ''; + const branchNames: string[] = []; + let hasNext = true; + let after: string | null = null; + try { while (hasNext) { - const result: any = await this.octokit.graphql(branchesQuery, { + const result: any = await this.octokit.graphql(listQuery, { owner, repo, after, }); - const refsData = result.repository?.refs; + const repoData = result.repository; + if (!repoData) break; + + // Capture default branch from first page + if (!defaultBranch && repoData.defaultBranchRef?.name) { + defaultBranch = repoData.defaultBranchRef.name; + } + + const refsData = repoData.refs; if (!refsData) break; - const nodes = refsData.nodes || []; - for (const node of nodes) { + for (const node of refsData.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 []; + console.error('❌ Failed to fetch branch list:', error?.message || error); + return { branches: [], commitCounts: new Map() }; } - } - /** - * Get all refs (branches) for a repository - */ - private async getRepositoryRefs( - owner: string, - repo: string - ): Promise> { - const refs: Array<{ name: string; type: 'branch' | 'tag' }> = []; + if (!defaultBranch) { + console.warn('⚠️ Could not determine default branch'); + return { branches: [], commitCounts: new Map() }; + } - const query = ` - query($owner: String!, $repo: String!, $after: String) { + console.log( + `📊 Found ${branchNames.length} branches (default: ${defaultBranch})` + ); + + // Step 2: For each branch, fetch commit history + ahead/behind in one query + const branchQuery = ` + query($owner: String!, $repo: String!, $branch: String!, $defaultBranch: String!, $since: GitTimestamp!, $until: GitTimestamp!) { repository(owner: $owner, name: $repo) { - refs(first: 100, refPrefix: "refs/heads/", after: $after) { - pageInfo { - hasNextPage - endCursor + ref(qualifiedName: $branch) { + target { + ... on Commit { + oid + committedDate + history(first: 100, since: $since, until: $until) { + pageInfo { hasNextPage endCursor } + nodes { + oid + author { name user { login } } + } + } + } } - nodes { - name + compare(headRef: $defaultBranch) { + aheadBy + behindBy } } } } `; - let hasNext = true; - let after: string | null = null; - - try { - while (hasNext) { - const result: any = await this.octokit.graphql(query, { + for (const branchName of branchNames) { + try { + const isDefaultBranch = branchName === defaultBranch; + const result: any = await this.octokit.graphql(branchQuery, { owner, repo, - after, + branch: `refs/heads/${branchName}`, + defaultBranch: `refs/heads/${defaultBranch}`, + since: sinceIso, + until: untilIso, }); - const refsData = result.repository?.refs; - if (!refsData) break; - - const nodes = refsData.nodes || []; - for (const node of nodes) { - refs.push({ - name: `refs/heads/${node.name}`, - type: 'branch', - }); + const ref = result.repository?.ref; + const target = ref?.target; + const history = target?.history; + const contributorsSet = new Set(); + + if (history?.nodes) { + for (const node of history.nodes) { + const sha = node.oid; + const login = + node.author?.user?.login || node.author?.name || 'unknown'; + + // Track contributors for this branch display + if (login) contributorsSet.add(login); + + // Deduplicated commit counting across all branches + if (!seenShas.has(sha)) { + seenShas.add(sha); + if (!login.endsWith('[bot]')) { + commitCounts.set(login, (commitCounts.get(login) || 0) + 1); + } + } + } } - hasNext = refsData.pageInfo?.hasNextPage || false; - after = refsData.pageInfo?.endCursor || null; + const aheadBy = isDefaultBranch ? undefined : ref?.compare?.aheadBy; + const behindBy = isDefaultBranch ? undefined : ref?.compare?.behindBy; + + branches.push({ + name: branchName, + last_commit_sha: target?.oid || '', + last_commit_date: target?.committedDate || new Date().toISOString(), + 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 + ); } - } catch (error: any) { - console.error('❌ Failed to fetch refs:', error?.message || error); } - return refs; + console.log( + `🎉 Completed fetching ${branches.length} branches, ${commitCounts.size} contributors with ${seenShas.size} unique commits` + ); + return { branches, commitCounts }; } }