Skip to content

Adds the ability to include data from organization repositories #2459

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

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
2 changes: 2 additions & 0 deletions api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default async (req, res) => {
disable_animations,
border_radius,
border_color,
role,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well I totally forgot what this ownerAffiliation does, but why does this need to be controlled by the user?

Copy link
Collaborator Author

@rickstaa rickstaa Jan 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They do not. Another option I looked at was to use a include_orgs variable and only add the COLLABORATOR flag. I did this in #2277, but people had problems (see #1 (comment)). For some reason, when you are the creator of a repository, you have to add yourself as a collaborator since github does not do this automatically even though you have contributed to the repository (see #1 (comment)).

In short, due to limitations in the GraphQL API, there currently is no clean way to implement this. The only way to get the language data in all repositories a user contributed is by looping through the commits, which is infeasible given the current GraphQL and Vercel rate limits. 😅 I created a feature request to improve this, but I think it's improbable GitHub will implement this (see community/community#18230 and community/community#36108).

I'm therefore also okay with not merging an organization-related PR at all, but I just thought implementing it as a (hidden) experimental feature might help some people who code most of their projects in organizations. 🤔👍

} = req.query;
res.setHeader("Content-Type", "image/svg+xml");

Expand All @@ -53,6 +54,7 @@ export default async (req, res) => {
parseBoolean(count_private),
parseBoolean(include_all_commits),
parseArray(exclude_repo),
parseArray(role),
);

const cacheSeconds = clampValue(
Expand Down
2 changes: 2 additions & 0 deletions api/top-langs.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default async (req, res) => {
locale,
border_radius,
border_color,
role,
disable_animations,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
Expand All @@ -44,6 +45,7 @@ export default async (req, res) => {
try {
const topLangs = await fetchTopLanguages(
username,
parseArray(role),
parseArray(exclude_repo),
);

Expand Down
38 changes: 38 additions & 0 deletions src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,16 @@ const CONSTANTS = {
ONE_DAY: 86400,
};

const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"];

const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY:
"Please add an env variable called PAT_1 with your github token in vercel",
USER_NOT_FOUND: "Make sure the provided username is not an organization",
GRAPHQL_ERROR: "Please try again later",
INVALID_AFFILIATION: `Invalid owner affiliations. Valid values are: ${OWNER_AFFILIATIONS.join(
", ",
)}`,
};

/**
Expand All @@ -324,6 +329,7 @@ class CustomError extends Error {
static MAX_RETRY = "MAX_RETRY";
static USER_NOT_FOUND = "USER_NOT_FOUND";
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
static INVALID_AFFILIATION = "INVALID_AFFILIATION";
}

/**
Expand Down Expand Up @@ -423,6 +429,36 @@ const parseEmojis = (str) => {
return toEmoji.get(emoji) || "";
});
};
/**
* Parse owner affiliations.
*
* @param {string[]} affiliations
* @returns {string[]} Parsed affiliations.
*
* @throws {CustomError} If affiliations contains invalid values.
*/
const parseOwnerAffiliations = (affiliations) => {
// Set default value for ownerAffiliations.
// NOTE: Done here since parseArray() will always return an empty array even nothing
//was specified.
affiliations =
affiliations && affiliations.length > 0
? affiliations.map((affiliation) => affiliation.toUpperCase())
: ["OWNER"];

// Check if ownerAffiliations contains valid values.
if (
affiliations.some(
(affiliation) => !OWNER_AFFILIATIONS.includes(affiliation),
)
) {
throw new CustomError(
"Invalid query parameter",
CustomError.INVALID_AFFILIATION,
);
}
return affiliations;
};

export {
ERROR_CARD_LENGTH,
Expand All @@ -441,10 +477,12 @@ export {
wrapTextMultiline,
logger,
CONSTANTS,
OWNER_AFFILIATIONS,
CustomError,
MissingParamError,
measureText,
lowercaseTrim,
chunkArray,
parseEmojis,
parseOwnerAffiliations,
};
25 changes: 18 additions & 7 deletions src/fetchers/stats-fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
MissingParamError,
request,
wrapTextMultiline,
parseOwnerAffiliations,
} from "../common/utils.js";

dotenv.config();

// GraphQL queries.
const GRAPHQL_REPOS_FIELD = `
repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) {
repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {
totalCount
nodes {
name
Expand All @@ -32,15 +33,15 @@ const GRAPHQL_REPOS_FIELD = `
`;

const GRAPHQL_REPOS_QUERY = `
query userInfo($login: String!, $after: String) {
user(login: $login) {
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) {
query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) {
user(login: $login) {
name
login
Expand Down Expand Up @@ -92,16 +93,22 @@ const fetcher = (variables, token) => {
* Fetch stats information for a given username.
*
* @param {string} username Github username.
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @returns {Promise<import('../common/types').StatsFetcher>} GraphQL Stats object.
*
* @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true.
*/
const statsFetcher = async (username) => {
const statsFetcher = async (username, ownerAffiliations) => {
let stats;
let hasNextPage = true;
let endCursor = null;
while (hasNextPage) {
const variables = { login: username, first: 100, after: endCursor };
const variables = {
login: username,
first: 100,
after: endCursor,
ownerAffiliations: ownerAffiliations,
};
let res = await retryer(fetcher, variables);
if (res.data.errors) return res;

Expand Down Expand Up @@ -175,13 +182,16 @@ const totalCommitsFetcher = async (username) => {
* @param {string} username GitHub username.
* @param {boolean} count_private Include private contributions.
* @param {boolean} include_all_commits Include all commits.
* @param {string[]} exclude_repo Repositories to exclude. Default: [].
* @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER.
* @returns {Promise<import("./types").StatsData>} Stats data.
*/
const fetchStats = async (
username,
count_private = false,
include_all_commits = false,
exclude_repo = [],
ownerAffiliations = [],
) => {
if (!username) throw new MissingParamError(["username"]);

Expand All @@ -194,8 +204,9 @@ const fetchStats = async (
contributedTo: 0,
rank: { level: "C", score: 0 },
};
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);

let res = await statsFetcher(username);
let res = await statsFetcher(username, ownerAffiliations);

// Catch GraphQL errors.
if (res.data.errors) {
Expand Down
19 changes: 13 additions & 6 deletions src/fetchers/top-languages-fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MissingParamError,
request,
wrapTextMultiline,
parseOwnerAffiliations,
} from "../common/utils.js";

/**
Expand All @@ -19,10 +20,10 @@ const fetcher = (variables, token) => {
return request(
{
query: `
query userInfo($login: String!) {
query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) {
user(login: $login) {
# fetch only owner repos & not forks
repositories(ownerAffiliations: OWNER, isFork: false, first: 100) {
# do not fetch forks
repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) {
nodes {
name
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
Expand Down Expand Up @@ -51,13 +52,19 @@ const fetcher = (variables, token) => {
* Fetch top languages for a given username.
*
* @param {string} username GitHub username.
* @param {string[]} exclude_repo List of repositories to exclude.
* @param {string[]} exclude_repo List of repositories to exclude. Default: [].
* @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @returns {Promise<import("./types").TopLangData>} Top languages data.
*/
const fetchTopLanguages = async (username, exclude_repo = []) => {
const fetchTopLanguages = async (
username,
exclude_repo = [],
ownerAffiliations = [],
) => {
if (!username) throw new MissingParamError(["username"]);
ownerAffiliations = parseOwnerAffiliations(ownerAffiliations);

const res = await retryer(fetcher, { login: username });
const res = await retryer(fetcher, { login: username, ownerAffiliations });

if (res.data.errors) {
logger.error(res.data.errors);
Expand Down