diff --git a/client/src/components/NetworkGraphVisualization.tsx b/client/src/components/NetworkGraphVisualization.tsx index 943ada8..22ee5e8 100644 --- a/client/src/components/NetworkGraphVisualization.tsx +++ b/client/src/components/NetworkGraphVisualization.tsx @@ -194,6 +194,77 @@ export default function NetworkGraphVisualization({ } } + // Draw date axis at the bottom of the graph + // Commits are evenly spaced (not proportional to time), so we sample + // a subset of commit positions and label them with their actual dates. + { + // Build array of {x, date} sorted by x (same as chronological) + const dateTicks = layout.nodes + .map((n) => ({ x: n.x, ts: new Date(n.committedDate).getTime() })) + .filter((d) => d.ts > 0) + .sort((a, b) => a.x - b.x); + + if (dateTicks.length >= 2) { + const axisY = layout.totalHeight + 8; + const axisGroup = g.append('g') + .attr('class', 'date-axis') + .attr('transform', `translate(0,${axisY})`); + + // Draw the baseline + const minX = dateTicks[0].x; + const maxX = dateTicks[dateTicks.length - 1].x; + axisGroup.append('line') + .attr('x1', minX) + .attr('x2', maxX) + .attr('y1', 0) + .attr('y2', 0) + .style('stroke', '#30363d'); + + // Pick evenly-spaced samples from the commit positions + const maxTicks = Math.min(10, Math.max(3, Math.floor(viewWidth / 100))); + const step = Math.max(1, Math.floor((dateTicks.length - 1) / (maxTicks - 1))); + const samples: Array<{ x: number; ts: number }> = []; + for (let i = 0; i < dateTicks.length; i += step) { + samples.push(dateTicks[i]); + } + // Always include the last tick + if (samples[samples.length - 1] !== dateTicks[dateTicks.length - 1]) { + samples.push(dateTicks[dateTicks.length - 1]); + } + + const spanDays = (dateTicks[dateTicks.length - 1].ts - dateTicks[0].ts) / 86400000; + const fmtStr = spanDays > 365 ? '%b %Y' : '%b %d'; + const fmt = d3.timeFormat(fmtStr); + + // Filter out samples whose labels would overlap (min 60px apart) + const filtered: typeof samples = [samples[0]]; + for (let i = 1; i < samples.length; i++) { + if (samples[i].x - filtered[filtered.length - 1].x >= 60) { + filtered.push(samples[i]); + } + } + + for (const sample of filtered) { + // Tick mark + axisGroup.append('line') + .attr('x1', sample.x) + .attr('x2', sample.x) + .attr('y1', 0) + .attr('y2', 4) + .style('stroke', '#30363d'); + + // Label + axisGroup.append('text') + .attr('x', sample.x) + .attr('y', 18) + .attr('text-anchor', 'middle') + .style('fill', '#8b949e') + .style('font-size', '0.75rem') + .text(fmt(new Date(sample.ts))); + } + } + } + // Draw nodes const nodeGroup = g.append('g').attr('class', 'nodes'); @@ -304,7 +375,8 @@ export default function NetworkGraphVisualization({ zoom.transform(d3svg, zoomTransformRef.current); } else { const scaleX = viewWidth / (layout.totalWidth + 60); - const scaleY = viewHeight / (layout.totalHeight + 20); + // +50 to account for the date axis at the bottom + const scaleY = viewHeight / (layout.totalHeight + 50); const scale = Math.min(scaleX, scaleY, 2); const contentW = layout.totalWidth * scale; const contentH = layout.totalHeight * scale; diff --git a/client/src/contexts/DateRangeContext.tsx b/client/src/contexts/DateRangeContext.tsx index 229ac00..29c4013 100644 --- a/client/src/contexts/DateRangeContext.tsx +++ b/client/src/contexts/DateRangeContext.tsx @@ -1,9 +1,11 @@ import { createContext, useContext, useState, useMemo, useEffect } from 'react'; import type { ReactNode } from 'react'; -export type TimeRange = '1m' | '3m' | '6m' | '1y' | 'all' | 'custom'; +export type TimeRange = '1w' | '2w' | '1m' | '3m' | '6m' | '1y' | 'all' | 'custom'; export const TIME_RANGE_LABELS: Record = { + '1w': 'Last week', + '2w': 'Last 2 weeks', '1m': 'Last month', '3m': 'Last 3 months', '6m': 'Last 6 months', @@ -36,7 +38,9 @@ export function getDateRange(range: TimeRange): { since?: string; until?: string const now = new Date(); const untilDate = localToday(); const since = new Date(now); - if (range === '1m') since.setMonth(since.getMonth() - 1); + if (range === '1w') since.setDate(since.getDate() - 7); + else if (range === '2w') since.setDate(since.getDate() - 14); + else if (range === '1m') since.setMonth(since.getMonth() - 1); else if (range === '3m') since.setMonth(since.getMonth() - 3); else if (range === '6m') since.setMonth(since.getMonth() - 6); else if (range === '1y') since.setFullYear(since.getFullYear() - 1); @@ -58,9 +62,9 @@ interface DateRangeContextValue { const DateRangeContext = createContext(null); export function DateRangeProvider({ children }: { children: ReactNode }) { - const [timeRange, setTimeRange] = useState('1m'); - const [customSince, setCustomSince] = useState(() => getDateRange('1m').since!.slice(0, 10)); - const [customUntil, setCustomUntil] = useState(() => getDateRange('1m').until!.slice(0, 10)); + const [timeRange, setTimeRange] = useState('1w'); + const [customSince, setCustomSince] = useState(() => getDateRange('1w').since!.slice(0, 10)); + const [customUntil, setCustomUntil] = useState(() => getDateRange('1w').until!.slice(0, 10)); // Sync date inputs when switching to a preset useEffect(() => { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index aded8db..85a0247 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -119,6 +119,33 @@ export interface PullRequest { baseRefName: string; commits: { totalCount: number }; reviews: { totalCount: number }; + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + reviewRequests: Array<{ login: string; avatarUrl: string }>; + reviewList: Array<{ author: { login: string; avatarUrl: string } | null; state: string }>; +} + +export interface TagInfo { + name: string; + message: string | null; + taggerName: string | null; + taggerDate: string | null; + commitOid: string; + commitAbbreviatedOid: string; + committedDate: string; + commitMessage: string; + author: { + name: string | null; + avatarUrl: string; + login: string | null; + }; } export interface BranchInfo { @@ -256,6 +283,8 @@ export const api = { apiFetch(`/api/repos/${owner}/${repo}/pulls?state=${state}`), branches: (owner: string, repo: string) => apiFetch(`/api/repos/${owner}/${repo}/branches`), + tags: (owner: string, repo: string) => + apiFetch(`/api/repos/${owner}/${repo}/tags`), codeFrequency: ( owner: string, repo: string, diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 178a227..5c76aed 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -453,6 +453,41 @@ export default function AppLayout() { )} + {/* GitHub repo link */} + {params.owner && params.repo && ( + { + e.currentTarget.style.color = '#dfe2eb'; + e.currentTarget.style.background = 'rgba(88,166,255,0.1)'; + }} + onMouseOut={(e) => { + e.currentTarget.style.color = '#8b949e'; + e.currentTarget.style.background = 'transparent'; + }} + > + + + + + )} + {/* Spacer */}
diff --git a/client/src/pages/BranchesPage.tsx b/client/src/pages/BranchesPage.tsx index 70de592..f1b4d6c 100644 --- a/client/src/pages/BranchesPage.tsx +++ b/client/src/pages/BranchesPage.tsx @@ -2,9 +2,10 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; -import type { BranchInfo } from '../lib/api.js'; +import type { BranchInfo, TagInfo } from '../lib/api.js'; type SortMode = 'updated' | 'name' | 'ahead'; +type TabMode = 'branches' | 'tags'; function timeAgo(dateStr: string): string { if (!dateStr) return ''; @@ -202,17 +203,119 @@ function BranchRow({ ); } +function TagRow({ tag }: { tag: TagInfo }) { + const date = tag.taggerDate ?? tag.committedDate; + const authorLogin = tag.author.login ?? tag.author.name ?? ''; + return ( +
+ {/* Tag icon + name */} +
+
+ 🏷️ + + {tag.name} + +
+
+ + {/* Commit message */} +
+ {(tag.message?.split('\n')[0] ?? tag.commitMessage.split('\n')[0])} +
+ + {/* Author + age */} +
+ {tag.author.avatarUrl && ( + {authorLogin} + )} + + {timeAgo(date)} + +
+ + {/* Commit SHA */} +
+ + {tag.commitAbbreviatedOid} + +
+
+ ); +} + export default function BranchesPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const navigate = useNavigate(); const [sortMode, setSortMode] = useState('updated'); + const [activeTab, setActiveTab] = useState('branches'); + const [searchQuery, setSearchQuery] = useState(''); - const { data: branches, isLoading, error } = useQuery({ + const { data: branches, isLoading: branchesLoading, error: branchesError } = useQuery({ queryKey: ['branches', owner, repo], queryFn: () => api.repos.branches(owner!, repo!), enabled: !!owner && !!repo, }); + const { data: tags, isLoading: tagsLoading, error: tagsError } = useQuery({ + queryKey: ['tags', owner, repo], + queryFn: () => api.repos.tags(owner!, repo!), + enabled: !!owner && !!repo && activeTab === 'tags', + }); + if (!owner || !repo) { return (
b.name.toLowerCase().includes(q)) : sorted; + const filteredTags = q && tags ? tags.filter((t) => t.name.toLowerCase().includes(q)) : (tags ?? []); + + const isLoading = activeTab === 'branches' ? branchesLoading : tagsLoading; + const error = activeTab === 'branches' ? branchesError : tagsError; + const isEmpty = activeTab === 'branches' ? filteredBranches.length === 0 : filteredTags.length === 0; + return (
- 🏷️ - + 🌿 + Branches {owner}/{repo} + + {/* Search bar */} +
+ + + + setSearchQuery(e.target.value)} + placeholder="Search..." + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + color: '#dfe2eb', + fontSize: '0.8125rem', + width: 140, + }} + /> +
+
- {/* Sort controls */} - Sort: - {(['updated', 'name', 'ahead'] as SortMode[]).map((mode) => ( + + {/* Sort controls (branches only) */} + {activeTab === 'branches' && ( + <> + Sort: + {(['updated', 'name', 'ahead'] as SortMode[]).map((mode) => ( + + ))} + + )} +
+ + {/* Tabs */} +
+ {(['branches', 'tags'] as TabMode[]).map((tab) => ( ))}
{/* Column headers */} - {!isLoading && !error && sorted.length > 0 && ( + {!isLoading && !error && !isEmpty && (
- BRANCH + {activeTab === 'branches' ? 'BRANCH' : 'TAG'}
- LAST COMMIT + {activeTab === 'branches' ? 'LAST COMMIT' : 'MESSAGE'}
UPDATED
- AHEAD / BEHIND + {activeTab === 'branches' ? 'AHEAD / BEHIND' : 'COMMIT'}
)} @@ -352,7 +533,7 @@ export default function BranchesPage() { animation: 'spin 0.8s linear infinite', }} /> - Loading branches... + Loading {activeTab}...
) : error ? ( @@ -364,9 +545,9 @@ export default function BranchesPage() { fontSize: '0.875rem', }} > - {error instanceof Error ? error.message : 'Failed to load branches'} + {error instanceof Error ? error.message : `Failed to load ${activeTab}`}
- ) : sorted.length === 0 ? ( + ) : isEmpty ? (
- 🏷️ - No branches found + {activeTab === 'branches' ? '🌿' : '🏷️'} + + {searchQuery ? `No ${activeTab} matching "${searchQuery}"` : `No ${activeTab} found`} +
- ) : ( - sorted.map((branch) => ( + ) : activeTab === 'branches' ? ( + filteredBranches.map((branch) => ( )) + ) : ( + filteredTags.map((tag) => ( + + )) )}
diff --git a/client/src/pages/GraphPage.tsx b/client/src/pages/GraphPage.tsx index e7026b5..388d2c7 100644 --- a/client/src/pages/GraphPage.tsx +++ b/client/src/pages/GraphPage.tsx @@ -49,15 +49,13 @@ export default function GraphPage() { branchMap, isLoading: commitsLoading, error, - fetchNextPage, - hasNextPage, isFetchingNextPage, } = useMultiBranchCommits( owner!, repo!, effectiveBranches, !!owner && !!repo && effectiveBranches.length > 0, - false, + true, // auto-fetch all pages within the date range since, until ); @@ -245,32 +243,23 @@ export default function GraphPage() { )} - {/* Load more button */} - {hasNextPage && commits.length > 0 && ( + {/* Loading more indicator */} + {isFetchingNextPage && commits.length > 0 && (
- + Loading commits… {commits.length} loaded
)} diff --git a/client/src/pages/NetworkPage.tsx b/client/src/pages/NetworkPage.tsx index f605033..89486e3 100644 --- a/client/src/pages/NetworkPage.tsx +++ b/client/src/pages/NetworkPage.tsx @@ -53,20 +53,18 @@ export default function NetworkPage() { branchMap: mainBranchMap, isLoading: commitsLoading, error, - fetchNextPage, - hasNextPage, isFetchingNextPage, } = useMultiBranchCommits( owner!, repo!, fetchBranches, !!owner && !!repo && fetchBranches.length > 0, - false, // don't auto-fetch all pages — user clicks Load More + true, // auto-fetch all pages within the date range since, until ); - // Phase 2: Load other live branches ON DEMAND (not automatic) + // Phase 2: Auto-load other live branches const otherBranches = effectiveBranches.filter((b) => b !== defaultBranch); const [loadedBranchIdx, setLoadedBranchIdx] = useState(0); const [extraCommits, setExtraCommits] = useState([]); @@ -126,6 +124,14 @@ export default function NetworkPage() { const hasMoreBranches = loadedBranchIdx < otherBranches.length; + // Auto-load remaining branches once main commits are done + useEffect(() => { + if (!commitsLoading && !isFetchingNextPage && hasMoreBranches && !loadingExtra) { + loadMoreBranches(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commitsLoading, isFetchingNextPage, hasMoreBranches, loadingExtra, loadedBranchIdx]); + // Merge main + extra commits const { commits, branchMap } = useMemo(() => { const commitMap = new Map(); @@ -377,28 +383,16 @@ export default function NetworkPage() { /> )} - {/* Load more commits */} - {!commitsLoading && !isFetchingNextPage && hasNextPage && commits.length > 0 && ( -
- -
- )} - {isFetchingNextPage && ( -
- Loading more commits… + {/* Loading indicator */} + {(isFetchingNextPage || loadingExtra) && commits.length > 0 && ( +
+ Loading… {commits.length} commits + {loadingExtra ? `, ${otherBranches.length - loadedBranchIdx + extraCommits.length > 0 ? loadedBranchIdx : 0} of ${otherBranches.length} branches` : ''}
)} @@ -417,33 +411,6 @@ export default function NetworkPage() { No commits found for the selected branch(es)
)} - - {/* Load more branches button */} - {!commitsLoading && hasMoreBranches && !loadingExtra && commits.length > 0 && ( -
- -
- )} {/* Commit detail sidebar */} diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index ac123f8..18efbf6 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; @@ -6,7 +6,7 @@ import type { PullRequest } from '../lib/api.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; -type PRState = 'OPEN' | 'CLOSED' | 'MERGED'; +type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; function timeAgo(dateStr: string): string { @@ -64,6 +64,333 @@ function StateBadge({ state }: { state: string }) { ); } +type CheckNode = + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null }; + +function CIStatusIcon({ rollup }: { pr: PullRequest; rollup: PullRequest['statusCheckRollup'] }) { + const [showTooltip, setShowTooltip] = useState(false); + const containerRef = useRef(null); + + if (!rollup) { + return ( + + ⚪ + + ); + } + + const icon = + rollup.state === 'SUCCESS' + ? '✅' + : rollup.state === 'FAILURE' || rollup.state === 'ERROR' + ? '❌' + : rollup.state === 'PENDING' + ? '⏳' + : '⚪'; + + const checks = rollup.contexts.nodes; + + function getCheckName(c: CheckNode): string { + return 'name' in c ? c.name : c.context; + } + function getCheckState(c: CheckNode): string { + if ('conclusion' in c) return c.conclusion ?? c.status; + return c.state; + } + function getCheckUrl(c: CheckNode): string | null { + return 'detailsUrl' in c ? c.detailsUrl : c.targetUrl; + } + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {icon} + {showTooltip && checks.length > 0 && ( +
+ {checks.map((c, i) => { + const name = getCheckName(c); + const st = getCheckState(c); + const url = getCheckUrl(c); + const stColor = + st === 'SUCCESS' || st === 'success' + ? '#3fb950' + : st === 'FAILURE' || st === 'failure' || st === 'ERROR' || st === 'error' + ? '#f85149' + : '#d29922'; + return ( +
+ + {url ? ( + + {name} + + ) : ( + {name} + )} + {st} +
+ ); + })} +
+ )} +
+ ); +} + +function ReviewerAvatars({ pr }: { pr: PullRequest }) { + // Build a merged list: requested reviewers + people who reviewed + const reviewed = new Map(); + + for (const r of pr.reviewList) { + if (r.author) { + const existing = reviewed.get(r.author.login); + // Prefer APPROVED / CHANGES_REQUESTED over COMMENTED + if (!existing || existing.state === 'COMMENTED') { + reviewed.set(r.author.login, { ...r.author, state: r.state }); + } + } + } + + const allReviewers: Array<{ login: string; avatarUrl: string; state: string }> = []; + for (const req of pr.reviewRequests) { + if (!reviewed.has(req.login)) { + allReviewers.push({ ...req, state: 'PENDING' }); + } + } + for (const [, r] of reviewed) { + allReviewers.push(r); + } + + if (allReviewers.length === 0) return null; + + return ( +
+ {allReviewers.slice(0, 5).map((r) => { + const ringColor = + r.state === 'APPROVED' + ? '#3fb950' + : r.state === 'CHANGES_REQUESTED' + ? '#f85149' + : r.state === 'COMMENTED' + ? '#d29922' + : '#484f58'; + return ( + {r.login} + ); + })} +
+ ); +} + +const SORT_LABELS: Record = { + newest: 'Newest', + oldest: 'Oldest', + 'most-comments': 'Most reviews', + 'most-reviews': 'Most commits', +}; + +function SortDropdown({ sortMode, setSortMode }: { sortMode: SortMode; setSortMode: (m: SortMode) => void }) { + const [open, setOpen] = useState(false); + const modes: SortMode[] = ['newest', 'oldest', 'most-comments', 'most-reviews']; + return ( +
+ + {open && ( +
+ {modes.map((m) => ( + + ))} +
+ )} +
+ ); +} + +function FilterDropdown({ + label, + options, + value, + onChange, +}: { + label: string; + options: string[]; + value: string; + onChange: (v: string) => void; +}) { + const [open, setOpen] = useState(false); + if (options.length === 0) return null; + return ( +
+ + {open && ( +
+ + {options.map((opt) => ( + + ))} +
+ )} +
+ ); +} + function PRRow({ pr }: { pr: PullRequest }) { return ( + + {/* Reviewer avatars */} +
+ + +
); } @@ -213,6 +546,8 @@ export default function PullRequestsPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const [activeState, setActiveState] = useState('OPEN'); const [sortMode, setSortMode] = useState('newest'); + const [authorFilter, setAuthorFilter] = useState(''); + const [reviewerFilter, setReviewerFilter] = useState(''); const { since, until } = useDateRange(); @@ -239,11 +574,26 @@ export default function PullRequestsPage() { } const tabs: { label: string; value: PRState }[] = [ + { label: 'All', value: 'ALL' }, { label: 'Open', value: 'OPEN' }, { label: 'Closed', value: 'CLOSED' }, { label: 'Merged', value: 'MERGED' }, ]; + // Compute unique authors and reviewers for filter dropdowns + const uniqueAuthors = prs + ? [...new Set(prs.map((p) => p.author?.login).filter((l): l is string => !!l))].sort() + : []; + + const uniqueReviewers = prs + ? [ + ...new Set([ + ...prs.flatMap((p) => p.reviewRequests.map((r) => r.login)), + ...prs.flatMap((p) => p.reviewList.map((r) => r.author?.login ?? '')).filter(Boolean), + ]), + ].sort() + : []; + const sortedPrs = prs ? [...prs].sort((a, b) => { switch (sortMode) { @@ -264,10 +614,17 @@ export default function PullRequestsPage() { const created = new Date(pr.createdAt).getTime(); if (since && created < new Date(since).getTime()) return false; if (until && created > new Date(until).getTime()) return false; + if (authorFilter && pr.author?.login !== authorFilter) return false; + if (reviewerFilter) { + const hasReviewer = + pr.reviewRequests.some((r) => r.login === reviewerFilter) || + pr.reviewList.some((r) => r.author?.login === reviewerFilter); + if (!hasReviewer) return false; + } return true; }); - const isDateFiltered = !!since || !!until; + const isFiltered = !!(since || until || authorFilter || reviewerFilter); const totalCount = sortedPrs.length; const filteredCount = filteredPrs.length; @@ -308,31 +665,24 @@ export default function PullRequestsPage() { {/* Date range picker */} - {/* Sort controls */} - Sort: - {(['newest', 'oldest', 'most-comments', 'most-reviews'] as SortMode[]).map((mode) => ( - - ))} + {/* Author filter */} + + + {/* Reviewer filter */} + + + {/* Sort dropdown */} + {/* Tabs */} @@ -367,8 +717,8 @@ export default function PullRequestsPage() { ))} - {/* Date filter count badge */} - {isDateFiltered && !isLoading && prs && ( + {/* Filter count badge */} + {isFiltered && !isLoading && prs && filteredCount < totalCount && ( - {filteredCount} of {totalCount} shown + Showing {filteredCount} of {totalCount} PRs )} @@ -436,7 +786,7 @@ export default function PullRequestsPage() { > 📋 - No {activeState.toLowerCase()} pull requests + No {activeState === 'ALL' ? '' : activeState.toLowerCase() + ' '}pull requests ) : filteredPrs.length === 0 ? ( @@ -453,7 +803,7 @@ export default function PullRequestsPage() { > 📋 - No pull requests in the selected date range + No pull requests match the current filters ) : ( diff --git a/server/src/routes/repo.routes.ts b/server/src/routes/repo.routes.ts index 757ab4a..cded430 100644 --- a/server/src/routes/repo.routes.ts +++ b/server/src/routes/repo.routes.ts @@ -190,6 +190,31 @@ router.get( } ); +// GET /api/repos/:owner/:repo/tags +router.get( + '/:owner/:repo/tags', + async (req: Request, res: Response): Promise => { + const owner = str(req.params['owner']); + const repo = str(req.params['repo']); + const cacheKey = cacheService.cacheKey(['tags', owner, repo]); + const cached = cacheService.get(cacheKey); + if (cached) { + res.json(cached); + return; + } + + try { + const service = getGitHubService(req); + const tags = await service.getTags(owner, repo); + cacheService.set(cacheKey, tags, 300); + res.json(tags); + } catch (err) { + console.error('Get tags error:', err); + res.status(500).json({ error: 'Failed to fetch tags' }); + } + } +); + // GET /api/repos/:owner/:repo/pulls router.get( '/:owner/:repo/pulls', diff --git a/server/src/services/github.service.ts b/server/src/services/github.service.ts index cb436f4..8b5df24 100644 --- a/server/src/services/github.service.ts +++ b/server/src/services/github.service.ts @@ -5,6 +5,7 @@ import type { CommitsPage, CommitDetail, PullRequestSummary, + TagInfo, UserRepo, UserOrg, ReposPage, @@ -268,11 +269,16 @@ export class GitHubService { repo: string, state?: string ): Promise { - const states = state ? [state.toUpperCase()] : ['OPEN']; + const upper = state?.toUpperCase(); + const states = upper === 'ALL' ? ['OPEN', 'CLOSED', 'MERGED'] : upper ? [upper] : ['OPEN']; const query = ` - query($owner: String!, $repo: String!, $states: [PullRequestState!]) { + query($owner: String!, $repo: String!, $states: [PullRequestState!], $cursor: String) { repository(owner: $owner, name: $repo) { - pullRequests(first: 50, states: $states, orderBy: { field: UPDATED_AT, direction: DESC }) { + pullRequests(first: 100, states: $states, orderBy: { field: UPDATED_AT, direction: DESC }, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } nodes { number title @@ -286,11 +292,54 @@ export class GitHubService { } headRefName baseRefName - commits { + commits(last: 1) { totalCount + nodes { + commit { + statusCheckRollup { + state + contexts(first: 25) { + nodes { + ... on CheckRun { + name + conclusion + status + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + } + } + } + } + } + } } - reviews { + reviews(first: 20) { totalCount + nodes { + author { + login + avatarUrl + } + state + } + } + reviewRequests(first: 10) { + nodes { + requestedReviewer { + ... on User { + login + avatarUrl + } + ... on Team { + name + avatarUrl + } + } + } } } } @@ -298,13 +347,227 @@ export class GitHubService { } `; + interface RawPR { + number: number; + title: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + headRefName: string; + baseRefName: string; + commits: { + totalCount: number; + nodes: Array<{ + commit: { + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + }; + }>; + }; + reviews: { + totalCount: number; + nodes: Array<{ + author: { login: string; avatarUrl: string } | null; + state: string; + }>; + }; + reviewRequests: { + nodes: Array<{ + requestedReviewer: + | { login: string; avatarUrl: string } + | { name: string; avatarUrl: string } + | null; + }>; + }; + } + + interface PRPageResult { + repository: { + pullRequests: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: RawPR[]; + }; + }; + } + + const allPRs: RawPR[] = []; + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const page: PRPageResult = await this.graphqlWithAuth(query, { owner, repo, states, cursor }); + + allPRs.push(...page.repository.pullRequests.nodes); + const pi = page.repository.pullRequests.pageInfo; + hasMore = pi.hasNextPage; + cursor = pi.endCursor; + } + + return allPRs.map((pr) => { + const lastCommitNode = pr.commits.nodes[0]; + const rollup = lastCommitNode?.commit.statusCheckRollup ?? null; + + const reviewRequests = pr.reviewRequests.nodes + .map((rr) => { + const rv = rr.requestedReviewer; + if (!rv) return null; + if ('login' in rv) return { login: rv.login, avatarUrl: rv.avatarUrl }; + if ('name' in rv) return { login: rv.name, avatarUrl: rv.avatarUrl }; + return null; + }) + .filter((r): r is { login: string; avatarUrl: string } => r !== null); + + return { + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + createdAt: pr.createdAt, + updatedAt: pr.updatedAt, + author: pr.author, + headRefName: pr.headRefName, + baseRefName: pr.baseRefName, + commits: { totalCount: pr.commits.totalCount }, + reviews: { totalCount: pr.reviews.totalCount }, + statusCheckRollup: rollup, + reviewRequests, + reviewList: pr.reviews.nodes.map((r) => ({ + author: r.author, + state: r.state, + })), + }; + }); + } + + async getTags(owner: string, repo: string): Promise { + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + refs(refPrefix: "refs/tags/", first: 50, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }, after: $cursor) { + nodes { + name + target { + ... on Tag { + message + tagger { name date } + target { + ... on Commit { + oid + abbreviatedOid + committedDate + message + author { name avatarUrl user { login } } + } + } + } + ... on Commit { + oid + abbreviatedOid + committedDate + message + author { name avatarUrl user { login } } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + `; + + interface TagNode { + name: string; + target: + | { + message?: string; + tagger?: { name: string; date: string } | null; + target?: { + oid: string; + abbreviatedOid: string; + committedDate: string; + message: string; + author: { name: string | null; avatarUrl: string; user: { login: string } | null } | null; + } | null; + // lightweight tag falls through as Commit + oid?: string; + abbreviatedOid?: string; + committedDate?: string; + author?: { name: string | null; avatarUrl: string; user: { login: string } | null } | null; + } + | null; + } + const result = await this.graphqlWithAuth<{ repository: { - pullRequests: { nodes: PullRequestSummary[] }; + refs: { + nodes: TagNode[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; }; - }>(query, { owner, repo, states }); + }>(query, { owner, repo, cursor: null }); + + return result.repository.refs.nodes.map((node) => { + const t = node.target; + if (!t) { + return { + name: node.name, + message: null, + taggerName: null, + taggerDate: null, + commitOid: '', + commitAbbreviatedOid: '', + committedDate: '', + commitMessage: '', + author: { name: null, avatarUrl: '', login: null }, + }; + } + + // Annotated tag: has tagger + nested target commit + if ('tagger' in t && t.tagger !== undefined) { + const commit = t.target; + return { + name: node.name, + message: t.message ?? null, + taggerName: t.tagger?.name ?? null, + taggerDate: t.tagger?.date ?? null, + commitOid: commit?.oid ?? '', + commitAbbreviatedOid: commit?.abbreviatedOid ?? '', + committedDate: commit?.committedDate ?? '', + commitMessage: commit?.message ?? '', + author: { + name: commit?.author?.name ?? null, + avatarUrl: commit?.author?.avatarUrl ?? '', + login: commit?.author?.user?.login ?? null, + }, + }; + } - return result.repository.pullRequests.nodes; + // Lightweight tag: target IS the commit + return { + name: node.name, + message: null, + taggerName: null, + taggerDate: null, + commitOid: t.oid ?? '', + commitAbbreviatedOid: t.abbreviatedOid ?? '', + committedDate: t.committedDate ?? '', + commitMessage: t.message ?? '', + author: { + name: t.author?.name ?? null, + avatarUrl: t.author?.avatarUrl ?? '', + login: t.author?.user?.login ?? null, + }, + }; + }); } async getBranches(owner: string, repo: string): Promise { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 3027ad9..f22dc10 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -118,6 +118,33 @@ export interface PullRequestSummary { baseRefName: string; commits: { totalCount: number }; reviews: { totalCount: number }; + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + reviewRequests: Array<{ login: string; avatarUrl: string }>; + reviewList: Array<{ author: { login: string; avatarUrl: string } | null; state: string }>; +} + +export interface TagInfo { + name: string; + message: string | null; + taggerName: string | null; + taggerDate: string | null; + commitOid: string; + commitAbbreviatedOid: string; + committedDate: string; + commitMessage: string; + author: { + name: string | null; + avatarUrl: string; + login: string | null; + }; } export interface GitHubUser {