Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import type { Metadata } from 'next';
import Reactions from '@/views/Reactions';

export const metadata: Metadata = { title: 'GitHub Reactions' };

export default function ReactionsPage() {
return <Reactions />;
}
27 changes: 26 additions & 1 deletion apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,38 @@ export interface UseSearchParamsStateOptions {
* // Functional updates
* setParams(prev => ({ page: prev.page + 1 }));
*/
const EMPTY_SEARCH_PARAMS = new URLSearchParams();
const emptySubscribe = () => () => {};
const getHydratedSnapshot = () => true;
const getServerSnapshot = () => false;

/**
* Return the real `useSearchParams()` result only after hydration. During SSR
* and the first client hydration render, returns an empty `URLSearchParams`.
*
* This avoids hydration mismatches when the app is rendered with
* `dynamic = 'force-static'`: Next.js silently returns empty search params
* during the static prerender but returns the real URL params on the client,
* which normally causes SSR/client divergence for any component that reads
* from the search params.
*/
function useHydrationSafeSearchParams() {
const hydrated = React.useSyncExternalStore(
emptySubscribe,
getHydratedSnapshot,
getServerSnapshot,
);
const real = useSearchParams();
return hydrated ? real : EMPTY_SEARCH_PARAMS;
}

export function useSearchParamsState<C extends {}>(
config: { [K in keyof C]: ParamConfig<C[K]> },
defaultOptions?: UseSearchParamsStateOptions,
): [C, SetState<C>] {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParams = useHydrationSafeSearchParams();

// Track previous state to preserve value references
const prevStateRef = React.useRef<C | null>(null);
Expand Down
33 changes: 33 additions & 0 deletions apps/code-infra-dashboard/src/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,36 @@ export function parseRepo(input: string): { owner: string; repo: string } {
}
return { owner, repo };
}

export interface IssueReactionTarget {
owner: string;
repo: string;
number: number;
}

const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?$/;

/**
* Parse a GitHub issue or pull request URL.
* Returns null if the URL is not a recognized issue/PR resource (comment URLs are rejected).
*/
export function parseIssueUrl(input: string): IssueReactionTarget | null {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return null;
}

if (url.hostname !== 'github.com' || url.hash) {
return null;
}

const pathMatch = ISSUE_PATH_RE.exec(url.pathname);
if (!pathMatch) {
return null;
}

const [, owner, repo, numberStr] = pathMatch;
return { owner, repo, number: Number(numberStr) };
}
7 changes: 7 additions & 0 deletions apps/code-infra-dashboard/src/views/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Box from '@mui/material/Box';
import NextLink from 'next/link';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import GitHubIcon from '@mui/icons-material/GitHub';
import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown';
import BuildIcon from '@mui/icons-material/Build';
import CompareIcon from '@mui/icons-material/Compare';
import AssessmentIcon from '@mui/icons-material/Assessment';
Expand Down Expand Up @@ -73,6 +74,12 @@ const tools: Tool[] = [
icon: <GitHubIcon />,
path: '/github-triage',
},
{
name: 'GitHub Reactions',
description: 'List every emoji reaction on a public GitHub issue or pull request',
icon: <ThumbsUpDownIcon />,
path: '/reactions',
},
];

export default function Landing() {
Expand Down
240 changes: 240 additions & 0 deletions apps/code-infra-dashboard/src/views/Reactions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
'use client';

import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import Alert from '@mui/material/Alert';
import NextLink from 'next/link';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Link from '@mui/material/Link';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import {
DataGridPremium,
useGridApiRef,
useKeepGroupedColumnsHidden,
type GridColDef,
} from '@mui/x-data-grid-premium';
import Heading from '../components/Heading';
import ErrorDisplay from '../components/ErrorDisplay';
import { useSearchParamsState } from '../hooks/useSearchParamsState';
import { octokit, parseIssueUrl, type IssueReactionTarget } from '../utils/github';

const EXAMPLES = [
{ label: 'mui-design-kits#10', url: 'https://github.com/mui/mui-design-kits/issues/10' },
{ label: 'mui-design-kits#111', url: 'https://github.com/mui/mui-design-kits/issues/111' },
].map((example) => ({
...example,
href: `/reactions?${new URLSearchParams({ url: example.url })}`,
}));

const EMOJI: Record<string, string> = {
'+1': '👍',
'-1': '👎',
laugh: '😄',
confused: '😕',
heart: '❤️',
hooray: '🎉',
rocket: '🚀',
eyes: '👀',
};

interface ReactionRow {
id: number;
content: string;
user: string;
userUrl: string;
}

interface ReactionsResult {
rows: ReactionRow[];
truncated: boolean;
}

const MAX_PAGES = 3;
const PAGE_SIZE = 100;

async function fetchReactions(
target: IssueReactionTarget,
unbounded: boolean,
): Promise<ReactionsResult> {
const iterator = octokit.paginate.iterator(octokit.rest.reactions.listForIssue, {
owner: target.owner,
repo: target.repo,
issue_number: target.number,
per_page: PAGE_SIZE,
});

const rows: ReactionRow[] = [];
let pages = 0;
let truncated = false;
for await (const response of iterator) {
pages += 1;
for (const reaction of response.data) {
rows.push({
id: reaction.id,
content: reaction.content,
user: reaction.user?.login ?? '(unknown)',
userUrl: reaction.user?.html_url ?? '',
});
}
if (!unbounded && pages >= MAX_PAGES && response.data.length === PAGE_SIZE) {
truncated = true;
break;
}
}

return { rows, truncated };
}

const COLUMNS: GridColDef<ReactionRow>[] = [
{
field: 'content',
headerName: 'Reaction',
width: 140,
valueFormatter: (value: string) => `${EMOJI[value] ?? ''} ${value}`.trim(),
},
{
field: 'user',
headerName: 'User',
flex: 1,
minWidth: 200,
renderCell: (cellParams) =>
cellParams.row.userUrl ? (
<Link
href={cellParams.row.userUrl}
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
@{cellParams.value}
</Link>
) : (
cellParams.value
),
},
];

function targetKey(target: IssueReactionTarget): string {
return `${target.owner}/${target.repo}#${target.number}`;
}

export default function Reactions() {
const [searchParams, setSearchParams] = useSearchParamsState(
{ url: { defaultValue: '' }, all: { defaultValue: '' } },
{ replace: true },
);

const unbounded = searchParams.all === '1';
const loadAllHref = `/reactions?${new URLSearchParams({ url: searchParams.url, all: '1' })}`;

const [draft, setDraft] = React.useState(searchParams.url);
React.useEffect(() => {
setDraft(searchParams.url);
}, [searchParams.url]);

const target = React.useMemo(
() => (searchParams.url ? parseIssueUrl(searchParams.url) : null),
[searchParams.url],
);
const parseError = Boolean(searchParams.url) && target === null;

const query = useQuery({
queryKey: ['reactions', target ? targetKey(target) : null, unbounded],
queryFn: () => fetchReactions(target!, unbounded),
enabled: Boolean(target),
staleTime: 60 * 1000,
retry: false,
});

const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setSearchParams({ url: draft.trim(), all: '' });
};

const apiRef = useGridApiRef();
const rowGroupingModel = React.useMemo(() => ['content'], []);
const initialState = useKeepGroupedColumnsHidden({ apiRef, rowGroupingModel });

return (
<Box
sx={{
mt: 4,
height: 'calc(100dvh - 120px)',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Heading level={1}>GitHub Reactions</Heading>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 2 }}>
Paste a public GitHub issue or pull request URL to list every reaction and the users who
left them. Only public repositories are supported. Examples:{' '}
{EXAMPLES.map((example, index) => (
<React.Fragment key={example.url}>
{index > 0 ? ', ' : null}
<Link component={NextLink} href={example.href}>
{example.label}
</Link>
</React.Fragment>
))}
</Typography>

<Box
component="form"
onSubmit={handleSubmit}
sx={{ mb: 2, display: 'flex', gap: 1, alignItems: 'center' }}
>
<TextField
fullWidth
size="small"
label="GitHub URL"
placeholder="https://github.com/owner/repo/issues/123"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<Button
type="submit"
variant="contained"
disabled={!draft.trim() || draft.trim() === searchParams.url}
>
Load
</Button>
</Box>

{parseError ? (
<Alert severity="warning" sx={{ mb: 2 }}>
Not a recognized GitHub URL. Expected an issue or pull request link.
</Alert>
) : null}

{query.data?.truncated ? (
<Alert severity="info" sx={{ mb: 2 }}>
Showing the first {MAX_PAGES * PAGE_SIZE} reactions. This issue has more —{' '}
<Link component={NextLink} href={loadAllHref}>
load all reactions
</Link>
. This will use more of your hourly GitHub API budget.
</Alert>
) : null}

{query.isError ? (
<ErrorDisplay title="Failed to load reactions" error={query.error as Error} />
) : (
<DataGridPremium
apiRef={apiRef}
rows={query.data?.rows ?? []}
columns={COLUMNS}
loading={query.isLoading}
density="compact"
disableRowSelectionOnClick
rowGroupingModel={rowGroupingModel}
initialState={initialState}
groupingColDef={{ headerName: 'Reaction', width: 240 }}
defaultGroupingExpansionDepth={-1}
sx={{ flex: 1, minHeight: 0, maxHeight: '100vh' }}
/>
)}
</Box>
);
}
Loading
Loading