443 lines
12 KiB
JavaScript
443 lines
12 KiB
JavaScript
// @ts-check
|
|
import axios from "axios";
|
|
import * as dotenv from "dotenv";
|
|
import githubUsernameRegex from "github-username-regex";
|
|
import { calculateRank } from "../calculateRank.js";
|
|
import { retryer } from "../common/retryer.js";
|
|
import {
|
|
buildSearchFilter,
|
|
CustomError,
|
|
logger,
|
|
MissingParamError,
|
|
request,
|
|
wrapTextMultiline,
|
|
parseOwnerAffiliations,
|
|
} from "../common/utils.js";
|
|
|
|
dotenv.config();
|
|
|
|
// GraphQL queries.
|
|
const GRAPHQL_REPOS_FIELD = `
|
|
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
|
|
totalCount
|
|
nodes {
|
|
name
|
|
stargazers {
|
|
totalCount
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
`;
|
|
|
|
const GRAPHQL_REPOS_QUERY = `
|
|
query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) {
|
|
user(login: $login, ownerAffiliations: $ownerAffiliations) {
|
|
${GRAPHQL_REPOS_FIELD}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const GRAPHQL_STATS_QUERY = `
|
|
query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $ownerAffiliations: [RepositoryAffiliation]) {
|
|
user(login: $login) {
|
|
name
|
|
login
|
|
contributionsCollection {
|
|
totalCommitContributions,
|
|
totalPullRequestReviewContributions
|
|
}
|
|
repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {
|
|
totalCount
|
|
}
|
|
pullRequests(first: 1) {
|
|
totalCount
|
|
}
|
|
mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {
|
|
totalCount
|
|
}
|
|
openIssues: issues(states: OPEN) {
|
|
totalCount
|
|
}
|
|
closedIssues: issues(states: CLOSED) {
|
|
totalCount
|
|
}
|
|
followers {
|
|
totalCount
|
|
}
|
|
repositoryDiscussions @include(if: $includeDiscussions) {
|
|
totalCount
|
|
}
|
|
repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {
|
|
totalCount
|
|
}
|
|
${GRAPHQL_REPOS_FIELD}
|
|
}
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
|
|
*/
|
|
|
|
/**
|
|
* Stats fetcher object.
|
|
*
|
|
* @param {object} variables Fetcher variables.
|
|
* @param {string} token GitHub token.
|
|
* @returns {Promise<AxiosResponse>} Axios response.
|
|
*/
|
|
const fetcher = (variables, token) => {
|
|
const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY;
|
|
return request(
|
|
{
|
|
query,
|
|
variables,
|
|
},
|
|
{
|
|
Authorization: `bearer ${token}`,
|
|
},
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Fetch stats information for a given username.
|
|
*
|
|
* @param {object} variables Fetcher variables.
|
|
* @param {string} variables.username GitHub username.
|
|
* @param {boolean} variables.includeMergedPullRequests Include merged pull requests.
|
|
* @param {boolean} variables.includeDiscussions Include discussions.
|
|
* @param {boolean} variables.includeDiscussionsAnswers Include discussions answers.
|
|
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
|
|
* @returns {Promise<AxiosResponse>} Axios response.
|
|
*
|
|
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true.
|
|
*/
|
|
const statsFetcher = async ({
|
|
username,
|
|
includeMergedPullRequests,
|
|
includeDiscussions,
|
|
includeDiscussionsAnswers,
|
|
ownerAffiliations,
|
|
}) => {
|
|
let stats;
|
|
let hasNextPage = true;
|
|
let endCursor = null;
|
|
while (hasNextPage) {
|
|
const variables = {
|
|
login: username,
|
|
first: 100,
|
|
after: endCursor,
|
|
includeMergedPullRequests,
|
|
includeDiscussions,
|
|
includeDiscussionsAnswers,
|
|
ownerAffiliations,
|
|
};
|
|
let res = await retryer(fetcher, variables);
|
|
if (res.data.errors) {
|
|
return res;
|
|
}
|
|
|
|
// Store stats data.
|
|
const repoNodes = res.data.data.user.repositories.nodes;
|
|
if (stats) {
|
|
stats.data.data.user.repositories.nodes.push(...repoNodes);
|
|
} else {
|
|
stats = res;
|
|
}
|
|
|
|
// Disable multi page fetching on public Vercel instance due to rate limits.
|
|
const repoNodesWithStars = repoNodes.filter(
|
|
(node) => node.stargazers.totalCount !== 0,
|
|
);
|
|
hasNextPage =
|
|
process.env.FETCH_MULTI_PAGE_STARS === "true" &&
|
|
repoNodes.length === repoNodesWithStars.length &&
|
|
res.data.data.user.repositories.pageInfo.hasNextPage;
|
|
endCursor = res.data.data.user.repositories.pageInfo.endCursor;
|
|
}
|
|
|
|
return stats;
|
|
};
|
|
|
|
/**
|
|
* Fetch all the commits for all the repositories of a given username.
|
|
*
|
|
* @param {string} username GitHub username.
|
|
* @returns {Promise<number>} Total commits.
|
|
*
|
|
* @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
|
|
* #92#issuecomment-661026467 and #211 for more information.
|
|
*/
|
|
const totalItemsFetcher = async (username, repo, owner, type, filter) => {
|
|
if (!githubUsernameRegex.test(username)) {
|
|
logger.log("Invalid username provided.");
|
|
throw new Error("Invalid username provided.");
|
|
}
|
|
|
|
// https://developer.github.com/v3/search/#search-commits
|
|
const fetchTotalItems = (variables, token) => {
|
|
return axios({
|
|
method: "get",
|
|
url:
|
|
`https://api.github.com/search/` +
|
|
type +
|
|
`?per_page=1&q=` +
|
|
buildSearchFilter(variables.repo, variables.owner).replaceAll(
|
|
" ",
|
|
"+",
|
|
) +
|
|
filter,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/vnd.github.cloak-preview",
|
|
Authorization: `token ${token}`,
|
|
},
|
|
});
|
|
};
|
|
|
|
let res;
|
|
try {
|
|
res = await retryer(fetchTotalItems, {
|
|
login: username,
|
|
repo,
|
|
owner,
|
|
});
|
|
} catch (err) {
|
|
logger.log(err);
|
|
throw new Error(err);
|
|
}
|
|
|
|
const totalCount = res.data.total_count;
|
|
if (isNaN(totalCount)) {
|
|
logger.error("GitHub error: " + JSON.stringify(res.data));
|
|
throw new CustomError(
|
|
"Could not fetch data from GitHub REST API.",
|
|
CustomError.GITHUB_REST_API_ERROR,
|
|
);
|
|
}
|
|
return totalCount;
|
|
};
|
|
|
|
const fetchRepoUserStats = async (
|
|
username,
|
|
repo,
|
|
owner,
|
|
include_prs_authored,
|
|
include_prs_commented,
|
|
include_prs_reviewed,
|
|
include_issues_authored,
|
|
include_issues_commented,
|
|
) => {
|
|
let stats = {};
|
|
if (include_prs_authored) {
|
|
stats.totalPRsAuthored = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"issues",
|
|
`author:${username}+type:pr`,
|
|
);
|
|
}
|
|
if (include_prs_commented) {
|
|
stats.totalPRsCommented = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"issues",
|
|
`commenter:${username}+-author:${username}+type:pr`,
|
|
);
|
|
}
|
|
if (include_prs_reviewed) {
|
|
stats.totalPRsReviewed = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"issues",
|
|
`reviewed-by:${username}+-author:${username}+type:pr`,
|
|
);
|
|
}
|
|
if (include_issues_authored) {
|
|
stats.totalIssuesAuthored = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"issues",
|
|
`author:${username}+type:issue`,
|
|
);
|
|
}
|
|
if (include_issues_commented) {
|
|
stats.totalIssuesCommented = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"issues",
|
|
`commenter:${username}+-author:${username}+type:issue`,
|
|
);
|
|
}
|
|
return stats;
|
|
};
|
|
|
|
/**
|
|
* @typedef {import("./types").StatsData} StatsData Stats data.
|
|
*/
|
|
|
|
/**
|
|
* Fetch stats for a given username.
|
|
*
|
|
* @param {string} username GitHub username.
|
|
* @param {boolean} include_all_commits Include all commits.
|
|
* @param {string[]} exclude_repo Repositories to exclude.
|
|
* @param {boolean} include_merged_pull_requests Include merged pull requests.
|
|
* @param {boolean} include_discussions Include discussions.
|
|
* @param {boolean} include_discussions_answers Include discussions answers.
|
|
* @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER.
|
|
* @returns {Promise<StatsData>} Stats data.
|
|
*/
|
|
const fetchStats = async (
|
|
username,
|
|
include_all_commits = false,
|
|
exclude_repo = [],
|
|
include_merged_pull_requests = false,
|
|
include_discussions = false,
|
|
include_discussions_answers = false,
|
|
repo = [],
|
|
owner = [],
|
|
include_prs_authored = false,
|
|
include_prs_commented = false,
|
|
include_prs_reviewed = false,
|
|
include_issues_authored = false,
|
|
include_issues_commented = false,
|
|
ownerAffiliations = [],
|
|
) => {
|
|
if (!username) {
|
|
throw new MissingParamError(["username"]);
|
|
}
|
|
|
|
const stats = {
|
|
name: "",
|
|
totalPRs: 0,
|
|
totalPRsMerged: 0,
|
|
mergedPRsPercentage: 0,
|
|
totalReviews: 0,
|
|
totalCommits: 0,
|
|
totalIssues: 0,
|
|
totalStars: 0,
|
|
totalDiscussionsStarted: 0,
|
|
totalDiscussionsAnswered: 0,
|
|
contributedTo: 0,
|
|
totalPRsAuthored: 0,
|
|
totalPRsCommented: 0,
|
|
totalPRsReviewed: 0,
|
|
totalIssuesAuthored: 0,
|
|
totalIssuesCommented: 0,
|
|
rank: { level: "C", percentile: 100 },
|
|
};
|
|
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);
|
|
|
|
let res = await statsFetcher({
|
|
username,
|
|
includeMergedPullRequests: include_merged_pull_requests,
|
|
includeDiscussions: include_discussions,
|
|
includeDiscussionsAnswers: include_discussions_answers,
|
|
ownerAffiliations,
|
|
});
|
|
|
|
// Catch GraphQL errors.
|
|
if (res.data.errors) {
|
|
logger.error(res.data.errors);
|
|
if (res.data.errors[0].type === "NOT_FOUND") {
|
|
throw new CustomError(
|
|
res.data.errors[0].message || "Could not fetch user.",
|
|
CustomError.USER_NOT_FOUND,
|
|
);
|
|
}
|
|
if (res.data.errors[0].message) {
|
|
throw new CustomError(
|
|
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
|
|
res.statusText,
|
|
);
|
|
}
|
|
throw new CustomError(
|
|
"Something went wrong while trying to retrieve the stats data using the GraphQL API.",
|
|
CustomError.GRAPHQL_ERROR,
|
|
);
|
|
}
|
|
|
|
const user = res.data.data.user;
|
|
|
|
stats.name = user.name || user.login;
|
|
|
|
// if include_all_commits, fetch all commits using the REST API.
|
|
if (include_all_commits) {
|
|
stats.totalCommits = await totalItemsFetcher(
|
|
username,
|
|
repo,
|
|
owner,
|
|
"commits",
|
|
`author:${username}`,
|
|
);
|
|
} else {
|
|
stats.totalCommits = user.contributionsCollection.totalCommitContributions;
|
|
}
|
|
let repoUserStats = await fetchRepoUserStats(
|
|
username,
|
|
repo,
|
|
owner,
|
|
include_prs_authored,
|
|
include_prs_commented,
|
|
include_prs_reviewed,
|
|
include_issues_authored,
|
|
include_issues_commented,
|
|
);
|
|
Object.assign(stats, repoUserStats);
|
|
|
|
stats.totalPRs = user.pullRequests.totalCount;
|
|
if (include_merged_pull_requests) {
|
|
stats.totalPRsMerged = user.mergedPullRequests.totalCount;
|
|
stats.mergedPRsPercentage =
|
|
(user.mergedPullRequests.totalCount / user.pullRequests.totalCount) * 100;
|
|
}
|
|
stats.totalReviews =
|
|
user.contributionsCollection.totalPullRequestReviewContributions;
|
|
stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
|
|
if (include_discussions) {
|
|
stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount;
|
|
}
|
|
if (include_discussions_answers) {
|
|
stats.totalDiscussionsAnswered =
|
|
user.repositoryDiscussionComments.totalCount;
|
|
}
|
|
stats.contributedTo = user.repositoriesContributedTo.totalCount;
|
|
|
|
// Retrieve stars while filtering out repositories to be hidden.
|
|
let repoToHide = new Set(exclude_repo);
|
|
|
|
stats.totalStars = user.repositories.nodes
|
|
.filter((data) => {
|
|
return !repoToHide.has(data.name);
|
|
})
|
|
.reduce((prev, curr) => {
|
|
return prev + curr.stargazers.totalCount;
|
|
}, 0);
|
|
|
|
stats.rank = calculateRank({
|
|
all_commits: include_all_commits,
|
|
commits: stats.totalCommits,
|
|
prs: stats.totalPRs,
|
|
reviews: stats.totalReviews,
|
|
issues: stats.totalIssues,
|
|
repos: user.repositories.totalCount,
|
|
stars: stats.totalStars,
|
|
followers: user.followers.totalCount,
|
|
});
|
|
|
|
return stats;
|
|
};
|
|
|
|
export { fetchStats, fetchRepoUserStats };
|
|
export default fetchStats;
|