Skip to content

Commit

Permalink
feat: show oyster contribution stats in admin dashboard 📊 (#390)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomas-salgado authored Jul 25, 2024
1 parent cb1adcb commit 864a0ec
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 0 deletions.
1 change: 1 addition & 0 deletions apps/admin-dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
43 changes: 43 additions & 0 deletions apps/admin-dashboard/app/routes/_dashboard.oyster.contributors.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader>();

return (
<Modal onCloseTo={Route['/']}>
<Modal.Header>
<Modal.Title>Oyster (GitHub) Contributions</Modal.Title>
<Modal.CloseButton />
</Modal.Header>

<div className="flex flex-col gap-2">
<Text>Unique Contributors (Chore): {uniqueContributorsChore}</Text>
<Text>Unique Contributors (Docs): {uniqueContributorsDocs}</Text>
<Text>Unique Contributors (Feature): {uniqueContributorsFeature}</Text>
<Text>Unique Contributors (Fix): {uniqueContributorsFix}</Text>
<Text>Total Contributors: {totalContributors}</Text>
</div>
</Modal>
);
}
2 changes: 2 additions & 0 deletions apps/admin-dashboard/app/shared/constants.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/modules/github/github.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PullRequest>;

// 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<string>();
const docsContributors = new Set<string>();
const featContributors = new Set<string>();
const fixContributors = new Set<string>();
const totalContributors = new Set<string>();

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<PullRequest[]> {
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;
}

0 comments on commit 864a0ec

Please sign in to comment.