-
Notifications
You must be signed in to change notification settings - Fork 206
feat: add GitHub contribution heatmap using GraphQL API #713
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import React from "react"; | ||
| import { ActivityCalendar } from "react-activity-calendar"; | ||
| import { Paper, Typography } from "@mui/material"; | ||
|
|
||
| interface ContributionHeatmapProps { | ||
| data: { | ||
| date: string; | ||
| count: number; | ||
| level: 0 | 1 | 2 | 3 | 4; | ||
| }[]; | ||
| } | ||
|
|
||
| const ContributionHeatmap: React.FC<ContributionHeatmapProps> = ({ data }) => { | ||
| return ( | ||
| <Paper elevation={2} sx={{ p: 3, mt: 4 }}> | ||
| <Typography variant="h6" gutterBottom> | ||
| Contribution Activity | ||
| </Typography> | ||
|
|
||
| <ActivityCalendar data={data} theme={{light: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'], | ||
| dark: ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'],}} /> | ||
| </Paper> | ||
| ); | ||
| }; | ||
|
|
||
| export default ContributionHeatmap; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,12 @@ interface GitHubItem { | |
| html_url: string; | ||
| } | ||
|
|
||
| interface ContributionDay { | ||
| date: string; | ||
| count: number; | ||
| level: 0 | 1 | 2 | 3 | 4; | ||
| } | ||
|
|
||
| interface FetchFilters { | ||
| search?: string; | ||
| repo?: string; | ||
|
|
@@ -31,6 +37,7 @@ export const useGitHubData = ( | |
| const [totalIssues, setTotalIssues] = useState(0); | ||
| const [totalPrs, setTotalPrs] = useState(0); | ||
| const [rateLimited, setRateLimited] = useState(false); | ||
| const [contributionData, setContributionData] = useState<ContributionDay[]>([]); | ||
|
|
||
| // Prevent stale responses overwriting latest data | ||
| const lastRequestId = useRef(0); | ||
|
|
@@ -86,6 +93,53 @@ export const useGitHubData = ( | |
| }; | ||
| }; | ||
|
|
||
| const fetchContributionData = async ( | ||
| octokit: Octokit, | ||
| username: string | ||
| ): Promise<ContributionDay[]> => { | ||
| const response: any = await (octokit as any).graphql( | ||
| ` | ||
| query($login: String!) { | ||
| user(login: $login) { | ||
| contributionsCollection { | ||
| contributionCalendar { | ||
| weeks { | ||
| contributionDays { | ||
| date | ||
| contributionCount | ||
| contributionLevel | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, | ||
| { | ||
| login: username, | ||
| } | ||
| ); | ||
|
|
||
| return response.user.contributionsCollection | ||
| .contributionCalendar.weeks | ||
| .flatMap((week: any) => | ||
| week.contributionDays.map((day: any) => ({ | ||
| date: day.date, | ||
| count: day.contributionCount, | ||
| level: | ||
| day.contributionLevel === "NONE" | ||
| ? 0 | ||
| : day.contributionLevel === "FIRST_QUARTILE" | ||
| ? 1 | ||
| : day.contributionLevel === "SECOND_QUARTILE" | ||
| ? 2 | ||
| : day.contributionLevel === "THIRD_QUARTILE" | ||
| ? 3 | ||
| : 4, | ||
| })) | ||
| ); | ||
| }; | ||
|
|
||
| const fetchData = useCallback( | ||
| async ( | ||
| username: string, | ||
|
|
@@ -113,6 +167,7 @@ export const useGitHubData = ( | |
| activeTab === 'pr' || activeTab === 'both'; | ||
|
|
||
| const requests: Promise<any>[] = []; | ||
| const contributionRequest = fetchContributionData(octokit, username); | ||
|
|
||
| if (shouldFetchIssues) { | ||
| requests.push( | ||
|
|
@@ -140,7 +195,11 @@ export const useGitHubData = ( | |
| ); | ||
| } | ||
|
|
||
| const results = await Promise.allSettled(requests); | ||
| const [results, contributionResult] = | ||
| await Promise.all([ | ||
| Promise.allSettled(requests), | ||
| contributionRequest, | ||
| ]); | ||
|
Comment on lines
+198
to
+202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the relevant section around the cited lines
nl -ba src/hooks/useGitHubData.ts | sed -n '150,320p'Repository: GitMetricsLab/github_tracker Length of output: 115 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect around the cited lines using cat -n (nl isn't available)
cat -n src/hooks/useGitHubData.ts | sed -n '160,290p'
# Find all occurrences of Promise.all / allSettled around contributionRequest
rg -n "contributionRequest|Promise\.all\(|allSettled\(" src/hooks/useGitHubData.tsRepository: GitMetricsLab/github_tracker Length of output: 4601 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find the helper implementation and where contribution state is defined
rg -n "function fetchContributionData|const fetchContributionData|fetchContributionData\\(" src/hooks/useGitHubData.ts
rg -n "setContributionData\\(|contributionData" src/hooks/useGitHubData.ts
# Print the top of the file to capture types/state initialization
head -n 140 src/hooks/useGitHubData.tsRepository: GitMetricsLab/github_tracker Length of output: 3637 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect further down for the catch block and any other contribution handling
cat -n src/hooks/useGitHubData.ts | sed -n '290,420p'
# Also search for setContributionData usages to ensure no other fallback exists
rg -n "setContributionData\\(" src/hooks/useGitHubData.tsRepository: GitMetricsLab/github_tracker Length of output: 725 Prevent contribution (heatmap) fetch failures from aborting issues/PR rendering.
💡 Suggested fix- const [results, contributionResult] =
- await Promise.all([
- Promise.allSettled(requests),
- contributionRequest,
- ]);
+ const [results, contributionResult] = await Promise.all([
+ Promise.allSettled(requests),
+ contributionRequest
+ .then((value) => ({ status: 'fulfilled' as const, value }))
+ .catch((reason) => ({ status: 'rejected' as const, reason })),
+ ]);
...
- setRateLimited(false);
- setContributionData(contributionResult);
+ setRateLimited(false);
+ if (contributionResult.status === 'fulfilled') {
+ setContributionData(contributionResult.value);
+ } else {
+ setContributionData([]);
+ setError((prev) => prev || 'Contribution activity is currently unavailable.');
+ }🤖 Prompt for AI Agents |
||
|
|
||
| // Ignore stale requests | ||
| if (requestId !== lastRequestId.current) { | ||
|
|
@@ -186,6 +245,7 @@ export const useGitHubData = ( | |
| } | ||
|
|
||
| setRateLimited(false); | ||
| setContributionData(contributionResult); | ||
| } catch (err: unknown) { | ||
| if (requestId !== lastRequestId.current) { | ||
| return; | ||
|
|
@@ -244,6 +304,7 @@ export const useGitHubData = ( | |
| prs, | ||
| totalIssues, | ||
| totalPrs, | ||
| contributionData, | ||
| loading, | ||
| error, | ||
| rateLimited, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ import { useTheme } from "@mui/material/styles"; | |
| import { useGitHubAuth } from "../../hooks/useGitHubAuth"; | ||
| import { useGitHubData } from "../../hooks/useGitHubData"; | ||
| import { KeyIcon } from "lucide-react"; | ||
| import ContributionHeatmap from "../../components/ContributionHeatmap"; | ||
|
|
||
| const ROWS_PER_PAGE = 10; | ||
|
|
||
|
|
@@ -55,15 +56,17 @@ const Home: React.FC = () => { | |
| setUsername, | ||
| token, | ||
| setToken, | ||
| error: authError, | ||
| getOctokit, | ||
| } = useGitHubAuth(); | ||
|
|
||
| const authError = ""; | ||
|
|
||
| const { | ||
| issues, | ||
| prs, | ||
| totalIssues, | ||
| totalPrs, | ||
| contributionData, | ||
| loading, | ||
| error: dataError, | ||
| fetchData, | ||
|
|
@@ -396,6 +399,11 @@ const Home: React.FC = () => { | |
| </TableContainer> | ||
| </Box> | ||
| )} | ||
| {contributionData.length > 0 ? ( | ||
| <ContributionHeatmap data={contributionData} /> | ||
| ) : ( | ||
| <p>No contribution data available</p> | ||
| )} | ||
|
Comment on lines
+402
to
+406
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate the empty-state message to post-fetch state. This block currently shows “No contribution data available” even before a fetch completes, which is misleading. 💡 Suggested fix- {contributionData.length > 0 ? (
- <ContributionHeatmap data={contributionData} />
- ) : (
- <p>No contribution data available</p>
- )}
+ {username && !loading && !dataError && (
+ contributionData.length > 0 ? (
+ <ContributionHeatmap data={contributionData} />
+ ) : (
+ <p>No contribution data available</p>
+ )
+ )}🤖 Prompt for AI Agents |
||
| </Container> | ||
| ); | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 274
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 3076
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 2419
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 4615
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 448
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 832
🌐 Web query:
GitHub GraphQL contributionLevel values NONE FIRST_QUARTILE SECOND_QUARTILE THIRD_QUARTILE FOURTH_QUARTILE💡 Result:
In the GitHub GraphQL API, the ContributionLevel enum defines the intensity of a user's contributions on a specific day relative to other days [1][2]. The values are defined as follows: NONE: No contributions occurred [1][2]. FIRST_QUARTILE: The lowest 25% of days with contributions [1][2]. SECOND_QUARTILE: The second lowest 25% of days with contributions; contains more contributions than the first quartile [1][2]. THIRD_QUARTILE: The second highest 25% of days with contributions; contains more contributions than the second quartile, but less than the fourth quartile [1][2]. FOURTH_QUARTILE: The highest 25% of days with contributions; contains more contributions than the third quartile [1][2]. This enum is typically used in the contributionsCollection object to help determine the visual representation of activity on a contribution calendar [1][3].
Citations:
🌐 Web query:
@octokit/core Octokit graphql method TypeScript generics graphql<T>💡 Result:
In
@octokit/core, the graphql method supports TypeScript generics to specify the expected return type of your GraphQL query [1]. You can apply these generics directly to the graphql call using the syntax octokit.graphql(query, variables) [1]. When you provide a type , the library will treat the response data as that type [1]. If you do not provide a generic, the return type defaults to any (or a general structure depending on your specific version and configuration) [1]. Example usage: interface MyData { repository: { name: string; stargazers: { totalCount: number; }; }; } const response = await octokit.graphql( query, { owner: "octokit", name: "core" }); Additionally, for general GraphQL usage with Octokit, you can import and use GraphQlQueryResponseData from@octokit/graphqlto help define your response structures [2][3]. For more advanced scenarios, such as paginated GraphQL queries, you can also use generics with the pagination plugin, as seen in@octokit/plugin-paginate-graphql[4].Citations:
graphql()octokit/graphql.js#80Replace explicit
anywith typed GraphQL response models.Lines 100, 125, and 126 use
any(const response: any = await (octokit as any).graphql(...),week: any,day: any), violating@typescript-eslint/no-explicit-anyand weakening type safety in the contributions fetch path (notablyusercan benull).octokit.graphql<...>(...).contributionLevel(NONE | FIRST_QUARTILE | ...) to the existingContributionDay['level'](0 | 1 | 2 | 3 | 4) and removeanyfrom theflatMap/mapcallbacks.Promise<any>[]at line 169.🧰 Tools
🪛 ESLint
[error] 100-100: Unexpected any. Specify a different type.
(
@typescript-eslint/no-explicit-any)[error] 100-100: Unexpected any. Specify a different type.
(
@typescript-eslint/no-explicit-any)🤖 Prompt for AI Agents