diff --git a/ts-backend/src/fetchers/repository.ts b/ts-backend/src/fetchers/repository.ts index 0f39eea..5934637 100644 --- a/ts-backend/src/fetchers/repository.ts +++ b/ts-backend/src/fetchers/repository.ts @@ -1,7 +1,8 @@ // Fetchers for repository data and metrics import { Organization, Repository } from "@octokit/graphql-schema"; -import { Fetcher, RepositoryResult } from ".."; +import { Fetcher } from ".."; +import { RepositoryResult } from '../../../types' export const addRepositoriesToResult: Fetcher = async ( result, @@ -48,6 +49,13 @@ export const addRepositoriesToResult: Fetcher = async ( collaborators { totalCount } + repositoryTopics(first: 20) { + nodes { + topic { + name + } + } + } } } } @@ -73,6 +81,7 @@ export const addRepositoriesToResult: Fetcher = async ( repositoryName: repo.name, repoNameWithOwner: repo.nameWithOwner, licenseName: repo.licenseInfo?.name || "No License", + topics: repo.repositoryTopics.nodes?.map((node) => node?.topic.name ), forksCount: repo.forkCount, watchersCount: repo.watchers.totalCount, starsCount: repo.stargazerCount, diff --git a/ts-backend/src/index.ts b/ts-backend/src/index.ts index 2f777ab..0c2a496 100644 --- a/ts-backend/src/index.ts +++ b/ts-backend/src/index.ts @@ -12,6 +12,7 @@ import { addRepositoriesToResult, } from "./fetchers"; import { checkRateLimit, CustomOctokit, personalOctokit } from "./lib/octokit"; +import { RepositoryResult } from '../../types' export interface Result { meta: { @@ -31,42 +32,6 @@ export interface Result { repositories: Record; } -export interface RepositoryResult { - // Repo metadata - repositoryName: string; - repoNameWithOwner: string; - licenseName: string; - - // Counts of various things - projectsCount: number; - projectsV2Count: number; - discussionsCount: number; - forksCount: number; - totalIssuesCount: number; - openIssuesCount: number; - closedIssuesCount: number; - totalPullRequestsCount: number; - openPullRequestsCount: number; - closedPullRequestsCount: number; - mergedPullRequestsCount: number; - watchersCount: number; - starsCount: number; - collaboratorsCount: number; - - // Flags - discussionsEnabled: boolean; - projectsEnabled: boolean; - issuesEnabled: boolean; - - // Calculated metrics - openIssuesAverageAge: number; - openIssuesMedianAge: number; - closedIssuesAverageAge: number; - closedIssuesMedianAge: number; - issuesResponseAverageAge: number; - issuesResponseMedianAge: number; -} - export type Fetcher = ( result: Result, octokit: CustomOctokit, diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..146b960 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,36 @@ +export interface RepositoryResult { + // Repo metadata + repositoryName: string; + repoNameWithOwner: string; + licenseName: string; + topics: string[]; + + // Counts of various things + projectsCount: number; + projectsV2Count: number; + discussionsCount: number; + forksCount: number; + totalIssuesCount: number; + openIssuesCount: number; + closedIssuesCount: number; + totalPullRequestsCount: number; + openPullRequestsCount: number; + closedPullRequestsCount: number; + mergedPullRequestsCount: number; + watchersCount: number; + starsCount: number; + collaboratorsCount: number; + + // Flags + discussionsEnabled: boolean; + projectsEnabled: boolean; + issuesEnabled: boolean; + + // Calculated metrics + openIssuesAverageAge: number; + openIssuesMedianAge: number; + closedIssuesAverageAge: number; + closedIssuesMedianAge: number; + issuesResponseAverageAge: number; + issuesResponseMedianAge: number; +} \ No newline at end of file diff --git a/who-metrics-ui/src/components/RepositoriesTable.tsx b/who-metrics-ui/src/components/RepositoriesTable.tsx index 1a7a660..2234d8e 100644 --- a/who-metrics-ui/src/components/RepositoriesTable.tsx +++ b/who-metrics-ui/src/components/RepositoriesTable.tsx @@ -31,11 +31,12 @@ import { useRef, useState } from 'react'; + +import { RepositoryResult } from '../../../types'; import Data from '../data/data.json'; +import TopicCell from './TopicCell'; const repos = Object.values(Data['repositories']); -type Repo = (typeof repos)[0]; - function inputStopPropagation(event: React.KeyboardEvent) { event.stopPropagation(); } @@ -43,6 +44,7 @@ function inputStopPropagation(event: React.KeyboardEvent) { type Filter = { repositoryName?: Record; licenseName?: Record; + topics?: Record; collaboratorsCount?: Array; watchersCount?: Array; openIssuesCount?: Array; @@ -50,6 +52,12 @@ type Filter = { closedIssuesCount?: Array; mergedPullRequestsCount?: Array; forksCount?: Array; + openIssuesMedianAge?: Array; + openIssuesAverageAge?: Array; + closedIssuesMedianAge?: Array; + closedIssuesAverageAge?: Array; + issuesResponseMedianAge?: Array; + issuesResponseAverageAge?: Array; }; type SelectOption = { @@ -57,10 +65,31 @@ type SelectOption = { value: string | number; }; +const millisecondsToDisplayString = (milliseconds: number) => { + const days = milliseconds / 1000 / 60 / 60 / 24; + if (days === 0) { + return 'N/A'; + } + if (days < 1) { + return `<1 day`; + } + + if (days < 2) { + return `1 day`; + } + + return `${Math.floor(days)} days`; +}; + // This selects a field to populate a dropdown with -const dropdownOptions = (field: keyof Repo, filter = ''): SelectOption[] => - Array.from(new Set(repos.map((repo) => repo[field]))) - .map((fieldName) => ({ +const dropdownOptions = (field: keyof RepositoryResult, filter = ''): SelectOption[] => { + let options = [] + if (field === 'topics'){ + options = Array.from(new Set(repos.flatMap(repo => repo.topics))).sort() + } else { + options = Array.from(new Set(repos.map((repo) => repo[field]))) + } + return options.map((fieldName) => ({ // some fields are boolean (hasXxEnabled), so we need to convert them to strings label: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName, value: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName, @@ -68,6 +97,7 @@ const dropdownOptions = (field: keyof Repo, filter = ''): SelectOption[] => .filter((fieldName) => (fieldName.value as string).toLowerCase().includes(filter.toLowerCase()), ); +}; // Helper function to get the selected option value from a filter and field const getSelectedOption = ( @@ -80,14 +110,14 @@ const getSelectedOption = ( // Renderer for the min/max filter inputs const MinMaxRenderer: FC<{ - headerCellProps: RenderHeaderCellProps; + headerCellProps: RenderHeaderCellProps; filters: Filter; updateFilters: ((filters: Filter) => void) & ((filters: (filters: Filter) => Filter) => void); filterName: keyof Filter; }> = ({ headerCellProps, filters, updateFilters, filterName }) => { return ( - {...headerCellProps}> + {...headerCellProps}> {({ ...rest }) => ( @@ -143,7 +173,7 @@ const MinMaxRenderer: FC<{ // Renderer for the searchable select filter const SearchableSelectRenderer: FC<{ - headerCellProps: RenderHeaderCellProps; + headerCellProps: RenderHeaderCellProps; filters: Filter; updateFilters: ((filters: Filter) => void) & ((filters: (filters: Filter) => Filter) => void); @@ -153,7 +183,7 @@ const SearchableSelectRenderer: FC<{ const allSelectOptions = dropdownOptions(filterName, filteredOptions); return ( - {...headerCellProps}> + {...headerCellProps}> {({ ...rest }) => ( ({ // re-created when filters are changed and filter loses focus const FilterContext = createContext(undefined); -type Comparator = (a: Repo, b: Repo) => number; +type Comparator = (a: RepositoryResult, b: RepositoryResult) => number; -const getComparator = (sortColumn: keyof Repo): Comparator => { +const getComparator = (sortColumn: keyof RepositoryResult): Comparator => { switch (sortColumn) { // number based sorting case 'closedIssuesCount': @@ -356,6 +386,12 @@ const getComparator = (sortColumn: keyof Repo): Comparator => { case 'openPullRequestsCount': case 'projectsCount': case 'watchersCount': + case 'openIssuesMedianAge': + case 'openIssuesAverageAge': + case 'closedIssuesMedianAge': + case 'closedIssuesAverageAge': + case 'issuesResponseMedianAge': + case 'issuesResponseAverageAge': return (a, b) => { if (a[sortColumn] === b[sortColumn]) { return 0; @@ -377,6 +413,17 @@ const getComparator = (sortColumn: keyof Repo): Comparator => { .toLowerCase() .localeCompare(b[sortColumn].toLowerCase()); }; + + // Multi option, alphabetical + case 'topics': + return (a, b) => { + const first = a[sortColumn].sort()[0] + const second = b[sortColumn].sort()[0] + if (!second) return -1; + if (!first) return 1; + return first.toLowerCase().localeCompare(second.toLowerCase()) + }; + default: throw new Error(`unsupported sortColumn: "${sortColumn}"`); } @@ -390,10 +437,13 @@ const defaultFilters: Filter = { licenseName: { all: true, }, + topics: { + all: true, + } }; // Helper for generating the csv blob -const generateCSV = (data: Repo[]): Blob => { +const generateCSV = (data: RepositoryResult[]): Blob => { const output = json2csv(data); return new Blob([output], { type: 'text/csv' }); }; @@ -402,11 +452,11 @@ const RepositoriesTable = () => { const [globalFilters, setGlobalFilters] = useState(defaultFilters); // This needs a type, technically it's a Column but needs to be typed - const labels: Record> = { + const labels: Record> = { Name: { key: 'repositoryName', name: 'Name', - + frozen: true, renderHeaderCell: (p) => ( { updateFilters={setGlobalFilters} /> ), + renderCell: (props) => ( + + {props.row.repositoryName} + + ), + }, + Topics: { + key: 'topics', + name: 'Topics', + width: 275, + renderHeaderCell: (p) => { + return ( + + ) + }, + renderCell: (props) => { + // tabIndex === 0 is used as a proxy when the Cell is selected. See https://github.com/adazzle/react-data-grid/pull/3236 + const isSelected = props.tabIndex === 0 + return + }, }, License: { key: 'licenseName', @@ -533,6 +613,124 @@ const RepositoriesTable = () => { ); }, }, + OpenIssuesMedianAge: { + key: 'openIssuesMedianAge', + name: 'Open Issues Median Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return millisecondsToDisplayString(p.row.openIssuesMedianAge); + }, + }, + OpenIssuesAverageAge: { + key: 'openIssuesAverageAge', + name: 'Open Issues Average Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return millisecondsToDisplayString(p.row.openIssuesAverageAge); + }, + }, + ClosedIssuesMedianAge: { + key: 'closedIssuesMedianAge', + name: 'Closed Issues Median Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return ( +
+ {millisecondsToDisplayString(p.row.closedIssuesMedianAge)} +
+ ); + }, + }, + ClosedIssuesAverageAge: { + key: 'closedIssuesAverageAge', + name: 'Closed Issues Average Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return ( +
+ {millisecondsToDisplayString(p.row.closedIssuesAverageAge)} +
+ ); + }, + }, + IssuesResponseMedianAge: { + key: 'issuesResponseMedianAge', + name: 'Issues Response Median Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return ( +
+ {millisecondsToDisplayString(p.row.issuesResponseMedianAge)} +
+ ); + }, + }, + IssuesResponseAverageAge: { + key: 'issuesResponseAverageAge', + name: 'Issues Response Average Age', + renderHeaderCell: (p) => { + return ( + + ); + }, + renderCell: (p) => { + return ( +
+ {millisecondsToDisplayString(p.row.issuesResponseAverageAge)} +
+ ); + }, + }, } as const; const dataGridColumns = Object.entries(labels).map( @@ -546,14 +744,14 @@ const RepositoriesTable = () => { const [sortColumns, setSortColumns] = useState([]); - const sortRepos = (inputRepos: Repo[]) => { + const sortRepos = (inputRepos: RepositoryResult[]) => { if (sortColumns.length === 0) { return repos; } const sortedRows = [...inputRepos].sort((a, b) => { for (const sort of sortColumns) { - const comparator = getComparator(sort.columnKey as keyof Repo); + const comparator = getComparator(sort.columnKey as keyof RepositoryResult); const compResult = comparator(a, b); if (compResult !== 0) { return sort.direction === 'ASC' ? compResult : -compResult; @@ -565,6 +763,18 @@ const RepositoriesTable = () => { return sortedRows; }; + const testTimeBasedFilter = ( + minDays: number | undefined, + maxDays: number | undefined, + timeInMs: number, + ) => { + const timeInDays = Math.floor(timeInMs / 1000 / 60 / 60 / 24); + minDays = minDays || 0; + maxDays = maxDays || Infinity; + + return timeInDays >= minDays && timeInDays <= maxDays; + }; + /** * Uses globalFilters to filter the repos that are then passed to sortRepos * @@ -574,11 +784,13 @@ const RepositoriesTable = () => { * This is kind of a mess, but it works */ const filterRepos = useCallback( - (inputRepos: Repo[]) => { + (inputRepos: RepositoryResult[]) => { const result = inputRepos.filter((repo) => { return ( ((globalFilters.repositoryName?.[repo.repositoryName] ?? false) || (globalFilters.repositoryName?.['all'] ?? false)) && + (( globalFilters.topics && Object.entries(globalFilters.topics).some(([selectedTopic, isSelected]) => isSelected && repo.topics.includes(selectedTopic))) || + (globalFilters.topics?.['all'] ?? false)) && ((globalFilters.licenseName?.[repo.licenseName] ?? false) || (globalFilters.licenseName?.['all'] ?? false)) && (globalFilters.collaboratorsCount @@ -618,6 +830,48 @@ const RepositoriesTable = () => { (globalFilters.forksCount ? (globalFilters.forksCount?.[0] ?? 0) <= repo.forksCount && repo.forksCount <= (globalFilters.forksCount[1] ?? Infinity) + : true) && + (globalFilters.openIssuesMedianAge + ? testTimeBasedFilter( + globalFilters.openIssuesMedianAge[0], + globalFilters.openIssuesMedianAge[1], + repo.openIssuesMedianAge, + ) + : true) && + (globalFilters.openIssuesAverageAge + ? testTimeBasedFilter( + globalFilters.openIssuesAverageAge[0], + globalFilters.openIssuesAverageAge[1], + repo.openIssuesAverageAge, + ) + : true) && + (globalFilters.closedIssuesMedianAge + ? testTimeBasedFilter( + globalFilters.closedIssuesMedianAge[0], + globalFilters.closedIssuesMedianAge[1], + repo.closedIssuesMedianAge, + ) + : true) && + (globalFilters.closedIssuesAverageAge + ? testTimeBasedFilter( + globalFilters.closedIssuesAverageAge[0], + globalFilters.closedIssuesAverageAge[1], + repo.closedIssuesAverageAge, + ) + : true) && + (globalFilters.issuesResponseMedianAge + ? testTimeBasedFilter( + globalFilters.issuesResponseMedianAge[0], + globalFilters.issuesResponseMedianAge[1], + repo.issuesResponseMedianAge, + ) + : true) && + (globalFilters.issuesResponseAverageAge + ? testTimeBasedFilter( + globalFilters.issuesResponseAverageAge[0], + globalFilters.issuesResponseAverageAge[1], + repo.issuesResponseAverageAge, + ) : true) ); }); @@ -628,16 +882,23 @@ const RepositoriesTable = () => { ); const displayRows = filterRepos(sortRepos(repos)); + const createdDate = new Date(Data.meta.createdAt); return (
-
- - - - {subTitle()} +
+ + + + + {subTitle()} + + + Last updated {createdDate.toLocaleDateString()} at{' '} + {createdDate.toLocaleTimeString()} +
diff --git a/who-metrics-ui/src/components/TopicCell.tsx b/who-metrics-ui/src/components/TopicCell.tsx new file mode 100644 index 0000000..2c22abd --- /dev/null +++ b/who-metrics-ui/src/components/TopicCell.tsx @@ -0,0 +1,40 @@ +import { useState } from "react" +import { Popover } from 'react-tiny-popover' +import { Box, Label} from '@primer/react' + +const TopicCell = ({topics, isSelected}: { + topics: string[] + isSelected: boolean +}) => { + const [isHovering, setIsHovering] = useState(false) + const isOpen = topics.length > 0 && (isHovering || isSelected) + + return ( + { + return ( + e.stopPropagation()} + sx={{ + backgroundColor: 'Background', + border: '1px solid', + borderColor: 'border.default', + }} + > + {topics.sort().map((topic) => )} + + ) + }}> + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className="space-x-1 m-1" + > + {topics.sort().map((topic) => )} + + + ) +} + +export default TopicCell \ No newline at end of file