// @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} 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} 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} 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} 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;