Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

.idea
package-lock.json
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,25 @@ clone github-contributions-chart & github-contributions-canvas
- browse to http://localhost:3000/
- select `JankariTech` theme
- enter GitHub username
- optionally enter the GitLab URL, username and access token (see below)
- click `Generate`
- download image

## Include contributions from a private GitLab instance

The chart can additionally contain the contributions a person made on a private GitLab instance.

All GitLab settings are entered directly in the form, no configuration file is needed:

- `GitLab URL` - the base URL of the GitLab instance, e.g. `https://gitlab.example.com`
- `GitLab Username` - the username on that instance
- `GitLab Access Token` - a personal access token with the `read_user` scope,
only needed if the instance / the profile is not publicly readable

The contributions are read from `<GitLab URL>/users/<username>/calendar.json` by the server
(the token is sent to the local API route in a header and only used for that request)
and are added to the GitHub contributions of the same day.
The daily counts and the yearly totals are summed up and the colour levels are recalculated.

Note: only days that are part of the GitHub contribution calendar of the user are shown,
so GitLab contributions of years in which the person had no GitHub activity at all are not displayed.
22 changes: 17 additions & 5 deletions src/pages/api/v1/[username].js
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { fetchDataForAllYears } from '../../../utils/api/fetch'

export default async (req, res) => {
const { username, format } = req.query;
const data = await fetchDataForAllYears(username, format);
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate')
res.json(data);
}
const { username, format, gitlabUsername, gitlabUrl } = req.query;
// the token is sent in a header so that it does not end up in URLs / logs
const gitlabToken = req.headers['x-gitlab-token'];
try {
const data = await fetchDataForAllYears(
username,
format,
gitlabUsername,
gitlabUrl,
gitlabToken
);
Comment on lines +4 to +14
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate')
res.json(data);
Comment on lines +15 to +16
} catch (error) {
res.status(500).json({ error: error.message });
}
}
47 changes: 43 additions & 4 deletions src/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
fetchData,
downloadJSON,
cleanUsername,
cleanGitlabUsername,
cleanGitlabUrl,
share,
copyToClipboard
} from "../utils/export";
Expand All @@ -16,6 +18,9 @@ const App = () => {
const contentRef = useRef();
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState("");
const [gitlabUsername, setGitlabUsername] = useState("");
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
const [theme, setTheme] = useState("standard");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
Expand All @@ -31,15 +36,23 @@ const App = () => {
e.preventDefault();

setUsername(cleanUsername(username));
setGitlabUsername(cleanGitlabUsername(gitlabUsername));
setGitlabUrl(cleanGitlabUrl(gitlabUrl));
setLoading(true);
setError(null);
setData(null);

fetchData(cleanUsername(username))
fetchData(cleanUsername(username), {
username: cleanGitlabUsername(gitlabUsername),
url: cleanGitlabUrl(gitlabUrl),
token: gitlabToken.trim()
})
.then((data) => {
setLoading(false);

if (data.years.length === 0) {
if (data.error) {
setError(data.error);
} else if (data.years.length === 0) {
setError("Could not find your profile");
} else {
setData(data);
Expand Down Expand Up @@ -87,8 +100,8 @@ const App = () => {
username: username,
themeName: theme,
scaleFactor: 12,
startingDate: '2020-12-08',
endDate: '2024-01-14',
startingDate: '2023-04-14',
endDate: '2026-07-31',
footerText: "Made by @sallar & friends - github-contributions.vercel.app"
});
contentRef.current.scrollIntoView({
Expand Down Expand Up @@ -182,6 +195,32 @@ const App = () => {
autoCapitalize="none"
autoFocus
/>
<input
placeholder="GitLab URL (optional), e.g. https://gitlab.example.com"
onChange={(e) => setGitlabUrl(e.target.value)}
value={gitlabUrl}
id="gitlab-url"
autoCorrect="off"
autoCapitalize="none"
/>
<input
placeholder="Your GitLab Username (optional)"
onChange={(e) => setGitlabUsername(e.target.value)}
value={gitlabUsername}
id="gitlab-username"
autoCorrect="off"
autoCapitalize="none"
/>
<input
type="password"
placeholder="GitLab Access Token (optional)"
onChange={(e) => setGitlabToken(e.target.value)}
value={gitlabToken}
id="gitlab-token"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
/>
<button type="submit" disabled={username.length <= 0 || loading}>
<span role="img" aria-label="Stars">
Expand Down
18 changes: 6 additions & 12 deletions src/styles/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,14 @@ h6 {
form {
margin: 2em 0 1em;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
}

input {
border: 0.1rem solid var(--input-border);
border-right: 0;
border-radius: 0.4rem;
padding: 0.6rem 1rem;
height: 4rem;
Expand Down Expand Up @@ -156,13 +157,11 @@ button:disabled {
cursor: not-allowed;
}

form input {
width: 30rem;
border-radius: 0.4rem 0 0 0.4rem;
}

form input,
form button {
border-radius: 0 0.4rem 0.4rem 0;
width: 30rem;
max-width: 100%;
border-radius: 0.4rem;
}

.App-buttons {
Expand Down Expand Up @@ -261,7 +260,6 @@ footer a {
}

form input {
margin-bottom: 0.5rem;
text-align: center;
}

Expand All @@ -276,10 +274,6 @@ footer a {
text-align: center;
}

form input {
flex: 1;
}

.App-themes-list {
display: flex;
flex-wrap: wrap;
Expand Down
106 changes: 71 additions & 35 deletions src/utils/api/fetch.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cheerio from "cheerio";
import _ from "lodash";
import { fetchGitlabContributions } from "./gitlab";

const COLOR_MAP = {
0: "#ebedf0",
Expand Down Expand Up @@ -33,7 +34,15 @@ async function fetchYears(username) {
});
}

async function fetchDataForYear(url, year, format) {
// GitHub uses 4 levels, the intensity of a day is calculated relative to the
// busiest day of the year
function intensityForCount(count, maxCount) {
if (count <= 0) return 0;
if (maxCount <= 0) return 0;
return Math.min(4, Math.ceil((count / maxCount) * 4));
}

async function fetchDataForYear(url, year, format, gitlabContributions = {}) {
const data = await fetch(`https://github.com${url}`, {
headers: {
"x-requested-with": "XMLHttpRequest"
Expand All @@ -53,47 +62,63 @@ async function fetchDataForYear(url, year, format) {
[contribCount] = contribText;
contribCount = parseInt(contribCount.replace(/,/g, ""), 10);
}
const parseDay = (day) => {
const $day = $(day);
const dateString = $day.attr("data-date");
const date = dateString.split("-").map((d) => parseInt(d, 10));
let dayCount = 0;
try {
const idContributionCount = $day.attr("id");
const contributionsText = $('[for="' + idContributionCount + '"]').text();
const match = contributionsText.match(/^(\d+) contribution.*\.$/);
dayCount = parseInt(match[1], 10);
} catch (e) {
// pass
dayCount = 0;
}

const gitlabCount = gitlabContributions[dateString] || 0;
dayCount += gitlabCount;

const value = {
date: dateString,
count: dayCount,
gitlabCount,
color: COLOR_MAP[$day.attr("data-level")],
intensity: $day.attr("data-level") || 0
};
return { date, value };
};

const days = $days.get().map((day) => parseDay(day));
const gitlabTotal = days.reduce((sum, { value }) => sum + value.gitlabCount, 0);

// the levels reported by GitHub don't know about the GitLab contributions,
// so they have to be recalculated as soon as there are any
if (gitlabTotal > 0) {
const maxCount = days.reduce(
(max, { value }) => Math.max(max, value.count),
0
);
days.forEach(({ value }) => {
value.intensity = intensityForCount(value.count, maxCount);
value.color = COLOR_MAP[value.intensity];
});
}

return {
year,
total: contribCount || 0,
total: (contribCount || 0) + gitlabTotal,
range: {
start: $($days.get(0)).attr("data-date"),
end: $($days.get($days.length - 1)).attr("data-date")
},
contributions: (() => {
const parseDay = (day, index) => {
const $day = $(day);
const date = $day
.attr("data-date")
.split("-")
.map((d) => parseInt(d, 10));
const color = COLOR_MAP[$day.attr("data-level")];
const idContributionCount = $day.attr("id")
contribCount = 0
try {
const contributionsText = $('[for="' + idContributionCount + '"]').text()
const match = contributionsText.match(/^(\d+) contribution.*\.$/);
contribCount = parseInt(match[1], 10);
} catch (e) {
// pass
contribCount = 0
}

const value = {
date: $day.attr("data-date"),
count: contribCount,
color,
intensity: $day.attr("data-level") || 0
};
return { date, value };
};

if (format !== "nested") {
return $days.get().map((day, index) => parseDay(day, index).value);
return days.map(({ value }) => value);
}

return $days.get().reduce((o, day, index) => {
const { date, value } = parseDay(day, index);
return days.reduce((o, { date, value }) => {
const [y, m, d] = date;
if (!o[y]) o[y] = {};
if (!o[y][m]) o[y][m] = {};
Expand All @@ -104,10 +129,21 @@ async function fetchDataForYear(url, year, format) {
};
}

export async function fetchDataForAllYears(username, format) {
const years = await fetchYears(username);
export async function fetchDataForAllYears(
username,
format,
gitlabUsername,
gitlabUrl,
gitlabToken
) {
const [years, gitlabContributions] = await Promise.all([
fetchYears(username),
fetchGitlabContributions(gitlabUsername, gitlabUrl, gitlabToken)
]);
return Promise.all(
years.map((year) => fetchDataForYear(year.href, year.text, format))
years.map((year) =>
fetchDataForYear(year.href, year.text, format, gitlabContributions)
)
).then((resp) => {
return {
years: (() => {
Expand Down
39 changes: 39 additions & 0 deletions src/utils/api/gitlab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Fetches the contribution calendar of a user on a (private) GitLab instance.
*
* The GitLab instance URL, the username and (for private instances) a personal
* access token with the `read_user` scope are entered in the form of the app
* and passed to the API route, they are not read from any configuration file.
*/

/**
* @returns {Promise<Object>} map of `YYYY-MM-DD` -> number of contributions
*/
export async function fetchGitlabContributions(username, gitlabUrl, gitlabToken) {
if (!username || !gitlabUrl) {
return {};
}

const baseUrl = gitlabUrl.trim().replace(/\/+$/, "");
const url = `${baseUrl}/users/${encodeURIComponent(
username
)}/calendar.json`;
Comment on lines +17 to +20

const headers = { accept: "application/json" };
if (gitlabToken) {
headers["PRIVATE-TOKEN"] = gitlabToken;
}

const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(
`Could not fetch GitLab contributions of "${username}" (${response.status})`
);
}

const calendar = await response.json();
return Object.entries(calendar).reduce((counts, [date, count]) => {
counts[date] = parseInt(count, 10) || 0;
return counts;
}, {});
}
Loading