From 864a0ec4c7d0334db1924cdab4bf1f05b42ab769 Mon Sep 17 00:00:00 2001 From: Tomas Salgado <91388965+tomas-salgado@users.noreply.github.com> Date: Thu, 25 Jul 2024 10:28:54 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20show=20oyster=20contribution=20stats=20?= =?UTF-8?q?in=20admin=20dashboard=20=F0=9F=93=8A=20=20(#390)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-dashboard/.env.example | 1 + .../routes/_dashboard.oyster.contributors.tsx | 43 +++++++ .../app/shared/constants.server.ts | 2 + packages/core/package.json | 1 + packages/core/src/modules/github/github.ts | 114 ++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 apps/admin-dashboard/app/routes/_dashboard.oyster.contributors.tsx create mode 100644 packages/core/src/modules/github/github.ts diff --git a/apps/admin-dashboard/.env.example b/apps/admin-dashboard/.env.example index a7bd4e96..72de56ce 100644 --- a/apps/admin-dashboard/.env.example +++ b/apps/admin-dashboard/.env.example @@ -15,6 +15,7 @@ SESSION_SECRET=_ # AIRTABLE_FAMILY_BASE_ID= # AIRTABLE_MEMBERS_TABLE_ID= # AIRTABLE_RESUME_BOOKS_BASE_ID= +# GITHUB_TOKEN= # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID= diff --git a/apps/admin-dashboard/app/routes/_dashboard.oyster.contributors.tsx b/apps/admin-dashboard/app/routes/_dashboard.oyster.contributors.tsx new file mode 100644 index 00000000..334a38ad --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.oyster.contributors.tsx @@ -0,0 +1,43 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +import { getOysterContributorStats } from '@oyster/core/github'; +import { Modal, Text } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { ensureUserAuthenticated } from '@/shared/session.server'; + +export async function loader({ request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const stats = await getOysterContributorStats(); + + return json(stats); +} + +export default function OysterContributorsModal() { + const { + totalContributors, + uniqueContributorsChore, + uniqueContributorsDocs, + uniqueContributorsFeature, + uniqueContributorsFix, + } = useLoaderData(); + + return ( + + + Oyster (GitHub) Contributions + + + +
+ Unique Contributors (Chore): {uniqueContributorsChore} + Unique Contributors (Docs): {uniqueContributorsDocs} + Unique Contributors (Feature): {uniqueContributorsFeature} + Unique Contributors (Fix): {uniqueContributorsFix} + Total Contributors: {totalContributors} +
+
+ ); +} diff --git a/apps/admin-dashboard/app/shared/constants.server.ts b/apps/admin-dashboard/app/shared/constants.server.ts index 0205d47a..cb708c86 100644 --- a/apps/admin-dashboard/app/shared/constants.server.ts +++ b/apps/admin-dashboard/app/shared/constants.server.ts @@ -13,6 +13,7 @@ const BaseEnvironmentConfig = z.object({ API_URL: EnvironmentVariable, DATABASE_URL: EnvironmentVariable, ENVIRONMENT: z.nativeEnum(Environment), + GITHUB_TOKEN: EnvironmentVariable, GOOGLE_CLIENT_ID: EnvironmentVariable, GOOGLE_CLIENT_SECRET: EnvironmentVariable, GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID: EnvironmentVariable, @@ -29,6 +30,7 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ AIRTABLE_FAMILY_BASE_ID: true, AIRTABLE_MEMBERS_TABLE_ID: true, AIRTABLE_RESUME_BOOKS_BASE_ID: true, + GITHUB_TOKEN: true, GOOGLE_CLIENT_ID: true, GOOGLE_CLIENT_SECRET: true, GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID: true, diff --git a/packages/core/package.json b/packages/core/package.json index 68321d5f..d9086fdb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "./education.ui": "./src/modules/education/education.ui.tsx", "./employment": "./src/modules/employment/index.ts", "./employment.server": "./src/modules/employment/index.server.ts", + "./github": "./src/modules/github/github.ts", "./mixpanel": "./src/modules/mixpanel/index.ts", "./object-storage": "./src/modules/object-storage/index.ts", "./resources": "./src/modules/resource/index.ts", diff --git a/packages/core/src/modules/github/github.ts b/packages/core/src/modules/github/github.ts new file mode 100644 index 00000000..71a78172 --- /dev/null +++ b/packages/core/src/modules/github/github.ts @@ -0,0 +1,114 @@ +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { ColorStackError } from '@/shared/errors'; + +// Environment Variables + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN as string; + +// Types + +const PullRequest = z.object({ + merged_at: z.coerce.date().nullable(), + title: z.string().trim().min(1), + user: z.object({ login: z.string().trim().min(1) }), +}); + +type PullRequest = z.infer; + +// Core + +/** + * Returns high-level Oyster contributor statistics. + * + * For now, we're only interested in the number of unique contributors for + * each type of contribution. We can expand this function to include more + * detailed statistics in the future. + */ +export async function getOysterContributorStats() { + const prs = await getMergedPullRequests(); + + const choreContributors = new Set(); + const docsContributors = new Set(); + const featContributors = new Set(); + const fixContributors = new Set(); + const totalContributors = new Set(); + + for (const pr of prs) { + const username = pr.user.login; + + totalContributors.add(username); + + const title = pr.title.trim(); + const prefix = title.split(':')[0].trim().toLowerCase(); + + match(prefix) + .with('chore', () => choreContributors.add(username)) + .with('docs', () => docsContributors.add(username)) + .with('feat', () => featContributors.add(username)) + .with('fix', () => fixContributors.add(username)) + .otherwise(() => {}); + } + + return { + totalContributors: totalContributors.size, + uniqueContributorsChore: choreContributors.size, + uniqueContributorsDocs: docsContributors.size, + uniqueContributorsFix: fixContributors.size, + uniqueContributorsFeature: featContributors.size, + }; +} + +/** + * Returns the merged pull requests for the Oyster repository. + * + * The GitHub API paginates the results, so we need to follow the `next` link + * in the response headers to fetch all the pull requests. See documentation + * below. + * + * @see https://docs.github.com/en/rest/pulls/pulls#list-pull-requests + * @see https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api#using-link-headers + */ +async function getMergedPullRequests(): Promise { + const result: PullRequest[] = []; + + let uri = + 'https://api.github.com/repos/colorstackorg/oyster/pulls?state=closed&per_page=100'; + + while (uri) { + const response = await fetch(uri, { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${GITHUB_TOKEN}`, + }, + }); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to fetch merged pull requests.') + .withContext({ response: json }) + .report(); + } + + const prs = PullRequest.array().parse(json); + + prs.forEach((pr) => { + if (pr.merged_at) { + result.push(pr); + } + }); + + const link = response.headers.get('link'); + + if (link) { + uri = link.match(/<(.*?)>; rel="next"/)?.[1] as string; + } else { + uri = ''; + } + } + + return result; +}