Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OpenSourceObserver stats to explorer #3344

Merged
merged 16 commits into from
Apr 30, 2024
Merged
5 changes: 4 additions & 1 deletion packages/grant-explorer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ REACT_APP_EXPLORER_DISABLE_MATCHING_ESTIMATES=false
REACT_APP_PASSPORT_API_KEY="test-key"
REACT_APP_PASSPORT_AVALANCHE_API_KEY="test-key"
REACT_APP_PASSPORT_API_COMMUNITY_ID="0000"
REACT_APP_PASSPORT_API_COMMUNITY_ID_AVALANCHE="0000"
REACT_APP_PASSPORT_API_COMMUNITY_ID_AVALANCHE="0000"

# create key following https://www.opensource.observer/docs/get-started/
MasterHW marked this conversation as resolved.
Show resolved Hide resolved
REACT_APP_OSO_API_KEY=
2 changes: 2 additions & 0 deletions packages/grant-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"ethereum-blockies": "^0.1.1",
"ethers": "^5.7.2",
"framer-motion": "^10.15.0",
"graphql": "^16.8.1",
"graphql-request": "^6.1.0",
"history": "^5.3.0",
"html-react-parser": "^3.0.7",
"https-browserify": "^1.0.0",
Expand Down
175 changes: 175 additions & 0 deletions packages/grant-explorer/src/features/api/oso.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { useState } from "react";
import useSWR from "swr";
import { Hex } from "viem";
import { gql, GraphQLClient } from "graphql-request";

const osoApiKey = process.env.REACT_APP_OSO_API_KEY as string;
const osoUrl = "https://opensource-observer.hasura.app/v1/graphql";
const graphQLClient = new GraphQLClient(osoUrl, {
headers: {
authorization: `Bearer ${osoApiKey}`,
},
});
let hasFetched = false;

interface IOSOId {
artifacts_by_project: {
project_id: Hex;
};
}

export interface IOSOStats {
code_metrics_by_project: {
contributors: number;
first_commit_date: number;
};
events_monthly_to_project: [
{
bucket_month: number;
amount: number;
},
{
bucket_month: number;
amount: number;
},
{
bucket_month: number;
amount: number;
},
{
bucket_month: number;
amount: number;
},
{
bucket_month: number;
amount: number;
},
{
bucket_month: number;
amount: number;
},
];
}

export function useOSO(projectGithub?: string) {
const emptyReturn: IOSOStats = {
code_metrics_by_project: {
contributors: 0,
first_commit_date: 0,
},
events_monthly_to_project: [
{
bucket_month: 0,
amount: 0,
},
{
bucket_month: 0,
amount: 0,
},
{
bucket_month: 0,
amount: 0,
},
{
bucket_month: 0,
amount: 0,
},
{
bucket_month: 0,
amount: 0,
},
{
bucket_month: 0,
amount: 0,
},
],
};
const [stats, setStats] = useState<IOSOStats | null>(null);

const getStatsFor = async (projectRegistryGithub: string) => {
if (osoApiKey === "")
throw new Error("OpenSourceObserver API key not set.");
const queryId = gql`{
artifacts_by_project(where: {artifact_name: {_ilike: "%${projectRegistryGithub}/%"}}
distinct_on: project_id
) {
project_id
}
}`;

try {
hasFetched = true;
const idData: IOSOId = await graphQLClient.request<IOSOId>(queryId);

if (!Array.isArray(idData.artifacts_by_project)) {
setStats(emptyReturn);
return;
}

const parsedId: IOSOId = {
artifacts_by_project: idData.artifacts_by_project[0],
};

const queryStats = gql`{
code_metrics_by_project(where: {project_id: {_eq: "${parsedId.artifacts_by_project.project_id}"}}) {
contributors
first_commit_date
}
events_monthly_to_project(
where: {project_id: {_eq: "${parsedId.artifacts_by_project.project_id}"}, event_type: {_eq: "COMMIT_CODE"}}
limit: 6
order_by: {bucket_month: desc}
) {
bucket_month
amount
}
}`;

const items: IOSOStats =
await graphQLClient.request<IOSOStats>(queryStats);

if (!Array.isArray(items.code_metrics_by_project)) {
setStats(emptyReturn);
return;
}

if (items.events_monthly_to_project.length === 6) {
const parsedItems: IOSOStats = {
code_metrics_by_project: items.code_metrics_by_project[0],
events_monthly_to_project: items.events_monthly_to_project,
};
setStats(parsedItems);
} else {
const parsedItems: IOSOStats = {
code_metrics_by_project: items.code_metrics_by_project[0],
events_monthly_to_project: emptyReturn.events_monthly_to_project,
};
setStats(parsedItems);
}
} catch (e) {
console.error(`No stats found for project: ${projectGithub}`);
console.error(e);
setStats(emptyReturn);
}
};

const { isLoading } = useSWR(osoUrl, {
fetcher: async () => projectGithub && getStatsFor(projectGithub),
revalidateOnMount: true,
});

if (stats === null && !hasFetched)
projectGithub && getStatsFor(projectGithub);
return {
/**
* Fetch OSO for stats on a project
* @param projectRegistryGithub projectGithub
*/
getStatsFor,
/**
* Stats for a project (loaded from OSO)
*/
stats,
isStatsLoading: isLoading,
};
}
15 changes: 15 additions & 0 deletions packages/grant-explorer/src/features/common/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ export const getFormattedRoundId = (roundId?: string | number): string => {
return roundId;
}
};

export function formatTimeAgo(dateString : number) {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime()); // Difference in milliseconds
const diffMonths = Math.round(diffTime / (1000 * 60 * 60 * 24 * 30)); // Convert to months

if (diffMonths === 0) {
return 'This month';
} else if (diffMonths === 1) {
return 'Last month';
} else {
return `${diffMonths} months`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface GrantListProps {
export const GrantList: React.FC<GrantListProps> = ({ grants }) => {
return (
<Flex gap={2} flexDir="column" py={6} px={3}>
<h4 className="text-3xl">Project milestones</h4>
{grants.length > 0 ? (
<>
<Text className="text-[18px]">Total grants ({grants.length})</Text>
Expand All @@ -20,7 +21,7 @@ export const GrantList: React.FC<GrantListProps> = ({ grants }) => {
url={getGapProjectUrl(grant.projectUID, grant.uid)}
/>
))}
<Text fontFamily="DM Mono" textAlign="center">
<Text fontFamily="DM Mono" textAlign="center" className={"text-xs"}>
Data provided by Karma via{" "}
<Link href={gapAppUrl} target="_blank">
<Text as="span" className="text-gitcoin-violet-500">
Expand Down
77 changes: 77 additions & 0 deletions packages/grant-explorer/src/features/round/OSO/ImpactStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from "react";
import { IOSOStats } from "../../api/oso";
import { Flex, Link, Text } from "@chakra-ui/react";
import { Stat } from "../ViewProjectDetails";
import { formatTimeAgo } from "../../common/utils/utils";


export const StatList = ({ stats }: { stats: IOSOStats | null }) => {
if (stats === null) return;
return (
stats.code_metrics_by_project.contributors > 0 ? (
<React.Fragment>
<h4 className="text-3xl mt-5 ml-4" >Impact stats</h4>
<Flex gap={2} flexDir={{base: 'column', md: 'row'}} py={6} px={3} >
<div
className={
"rounded-2xl bg-gray-50 flex-auto p-3 md:p-6 gap-4 flex flex-col"
}
>
<div> <Stat
isLoading={false}
value={`${formatTimeAgo(stats.code_metrics_by_project.first_commit_date)}`}
>
Project age
</Stat>
</div>
</div>
<div
className={
"rounded-2xl bg-gray-50 flex-auto p-3 md:p-6 gap-4 flex flex-col"
}
>
<Stat
isLoading={false}
value={`${stats.code_metrics_by_project.contributors}`}
>
Unique code contributors
</Stat>
</div>
<div
className={
"rounded-2xl bg-gray-50 flex-auto p-3 md:p-6 gap-4 flex flex-col"
}
>
<Stat
isLoading={false}
value={`${projectVelocity(stats)}`}
>
Velocity
</Stat>
</div>
</Flex>
<Text fontFamily="DM Mono" textAlign="center" mt={0} className={"text-xs"}>
Data provided by {" "}
<Link href={"https://www.opensource.observer/"} target="_blank">
<Text as="span" className="text-gitcoin-violet-500">
opensource.observer
</Text>
</Link>
</Text>
</React.Fragment>
) : (
<div>
</div>
)
);
};

function projectVelocity(stats : IOSOStats) {
const recentCommits = stats.events_monthly_to_project[0].amount + stats.events_monthly_to_project[1].amount + stats.events_monthly_to_project[2].amount;
const olderCommits = stats.events_monthly_to_project[3].amount + stats.events_monthly_to_project[4].amount + stats.events_monthly_to_project[5].amount;

if (recentCommits === 0 && olderCommits === 0) return 'unknown';
if (recentCommits >= (1.5 * olderCommits)) return 'increasing';
if (recentCommits <= 0.5 * olderCommits) return 'decreasing';
return 'steady';
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { useCartStorage } from "../../store";
import { Box, Skeleton, SkeletonText, Tab, Tabs } from "@chakra-ui/react";
import { GrantList } from "./KarmaGrant/GrantList";
import { useGap } from "../api/gap";
import { StatList } from "./OSO/ImpactStats";
import { useOSO } from "../api/oso";
import { CheckIcon, ShoppingCartIcon } from "@heroicons/react/24/outline";
import { DataLayer, useDataLayer } from "data-layer";
import { DefaultLayout } from "../common/DefaultLayout";
Expand Down Expand Up @@ -120,6 +122,7 @@ export default function ViewProjectDetails() {
round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true;

const { grants } = useGap(projectToRender?.projectRegistryId as string);
const { stats } = useOSO(projectToRender?.projectMetadata.projectGithub as string);

const currentTime = new Date();
const isAfterRoundEndDate =
Expand Down Expand Up @@ -178,7 +181,6 @@ export default function ViewProjectDetails() {
const {
projectMetadata: { title, description = "", bannerImg },
} = projectToRender ?? { projectMetadata: {} };

const projectDetailsTabs = useMemo(
() => [
{
Expand All @@ -202,11 +204,16 @@ export default function ViewProjectDetails() {
),
},
{
name: "Milestone updates",
content: <GrantList grants={grants} />,
name: "Impact Measurement",
content: (
<React.Fragment>
<StatList stats={stats} />
<GrantList grants={grants} />
</React.Fragment>
),
},
],
[grants, projectToRender, description]
[stats, grants, projectToRender, description]
);

const handleTabChange = (tabIndex: number) => {
Expand Down Expand Up @@ -584,7 +591,7 @@ export function ProjectStats() {
);
}

function Stat({
export function Stat({
value,
children,
isLoading,
Expand Down
Loading
Loading