diff --git a/.github/workflows/deploy-prep.py b/.github/workflows/deploy-prep.py new file mode 100644 index 00000000..794c19a2 --- /dev/null +++ b/.github/workflows/deploy-prep.py @@ -0,0 +1,10 @@ +import os + +file = open('./vercel.json', 'r') +str = file.read() +file = open('./vercel.json', 'w') + +str = str.replace('"maxDuration": 10', '"maxDuration": 30') + +file.write(str) +file.close() diff --git a/.github/workflows/deploy-prep.yml b/.github/workflows/deploy-prep.yml new file mode 100644 index 00000000..0626e13d --- /dev/null +++ b/.github/workflows/deploy-prep.yml @@ -0,0 +1,20 @@ +name: Deployment Prep +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + config: + if: github.repository == 'anuraghazra/github-readme-stats' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deployment Prep + run: python ./.github/workflows/deploy-prep.py + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: vercel + create_branch: true + push_options: "--force" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index b9d275f5..d45c76ba 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -5,6 +5,7 @@ on: jobs: e2eTests: if: + github.repository == 'anuraghazra/github-readme-stats' && github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' name: Perform 2e2 tests diff --git a/.github/workflows/empty-issues-closer.yaml b/.github/workflows/empty-issues-closer.yaml index b20eef32..a65ea63b 100644 --- a/.github/workflows/empty-issues-closer.yaml +++ b/.github/workflows/empty-issues-closer.yaml @@ -8,6 +8,7 @@ on: jobs: closeEmptyIssuesAndTemplates: + if: github.repository == 'anuraghazra/github-readme-stats' name: Close empty issues runs-on: ubuntu-latest steps: diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index c401d32e..03715bfa 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -4,6 +4,7 @@ on: jobs: triage: + if: github.repository == 'anuraghazra/github-readme-stats' runs-on: ubuntu-latest steps: - uses: actions/labeler@v4 diff --git a/.github/workflows/stale-theme-pr-closer.yaml b/.github/workflows/stale-theme-pr-closer.yaml index 9a624982..aa104feb 100644 --- a/.github/workflows/stale-theme-pr-closer.yaml +++ b/.github/workflows/stale-theme-pr-closer.yaml @@ -5,6 +5,7 @@ on: jobs: closeOldThemePrs: + if: github.repository == 'anuraghazra/github-readme-stats' name: Close stale 'invalid' theme PRs runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/top-issues-dashboard.yml b/.github/workflows/top-issues-dashboard.yml index 2b36e529..3a9ec82d 100644 --- a/.github/workflows/top-issues-dashboard.yml +++ b/.github/workflows/top-issues-dashboard.yml @@ -5,6 +5,7 @@ on: jobs: showAndLabelTopIssues: + if: github.repository == 'anuraghazra/github-readme-stats' name: Update top issues Dashboard. runs-on: ubuntu-latest steps: diff --git a/.github/workflows/update-langs.yaml b/.github/workflows/update-langs.yaml new file mode 100644 index 00000000..ad6bfb62 --- /dev/null +++ b/.github/workflows/update-langs.yaml @@ -0,0 +1,44 @@ +name: Update supported languages +on: + schedule: + - cron: "0 0 */30 * *" + +jobs: + updateLanguages: + if: github.repository == 'anuraghazra/github-readme-stats' + name: Update supported languages + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x] + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + env: + CI: true + + - name: Run update-languages-json.js script + run: npm run generate-langs-json + + - name: Create Pull Request if upstream language file is changed + uses: peter-evans/create-pull-request@v4 + with: + commit-message: "refactor: update languages JSON" + branch: "update_langs/patch" + delete-branch: true + title: Update languages JSON + body: + "The + [update-langs](https://github.com/anuraghazra/github-readme-stats/actions/workflows/update-langs.yaml) + action found new/updated languages in the [upstream languages JSON + file](https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml)." + labels: "ci, lang-card" diff --git a/api/status/pat-info.js b/api/status/pat-info.js new file mode 100644 index 00000000..775e0689 --- /dev/null +++ b/api/status/pat-info.js @@ -0,0 +1,139 @@ +/** + * @file Contains a simple cloud function that can be used to check which PATs are no + * longer working. It returns a list of valid PATs, expired PATs and PATs with errors. + * + * @description This function is currently rate limited to 1 request per 10 minutes. + */ + +import { logger, request, dateDiff } from "../../src/common/utils.js"; +export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 10 minutes + +/** + * Simple uptime check fetcher for the PATs. + * + * @param {import('axios').AxiosRequestHeaders} variables + * @param {string} token + */ +const uptimeFetcher = (variables, token) => { + return request( + { + query: ` + query { + rateLimit { + remaining + resetAt + }, + }`, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); +}; + +const getAllPATs = () => { + return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); +}; + +/** + * Check whether any of the PATs is expired. + */ +const getPATInfo = async (fetcher, variables) => { + const details = {}; + const PATs = getAllPATs(); + + for (const pat of PATs) { + try { + const response = await fetcher(variables, process.env[pat]); + const errors = response.data.errors; + const hasErrors = Boolean(errors); + const errorType = errors?.[0]?.type; + const isRateLimited = + (hasErrors && errorType === "RATE_LIMITED") || + response.data.data?.rateLimit?.remaining === 0; + + // Store PATs with errors. + if (hasErrors && errorType !== "RATE_LIMITED") { + details[pat] = { + status: "error", + error: { + type: errors[0].type, + message: errors[0].message, + }, + }; + continue; + } else if (isRateLimited) { + const date1 = new Date(); + const date2 = new Date(response.data?.data?.rateLimit?.resetAt); + details[pat] = { + status: "exhausted", + remaining: 0, + resetIn: dateDiff(date2, date1) + " minutes", + }; + } else { + details[pat] = { + status: "valid", + remaining: response.data.data.rateLimit.remaining, + }; + } + } catch (err) { + // Store the PAT if it is expired. + const errorMessage = err.response?.data?.message?.toLowerCase(); + if (errorMessage === "bad credentials") { + details[pat] = { + status: "expired", + }; + } else if (errorMessage === "sorry. your account was suspended.") { + details[pat] = { + status: "suspended", + }; + } else { + throw err; + } + } + } + + const filterPATsByStatus = (status) => { + return Object.keys(details).filter((pat) => details[pat].status === status); + }; + + const sortedDetails = Object.keys(details) + .sort() + .reduce((obj, key) => { + obj[key] = details[key]; + return obj; + }, {}); + + return { + validPATs: filterPATsByStatus("valid"), + expiredPATs: filterPATsByStatus("expired"), + exhaustedPATs: filterPATsByStatus("exhausted"), + suspendedPATs: filterPATsByStatus("suspended"), + errorPATs: filterPATsByStatus("error"), + details: sortedDetails, + }; +}; + +/** + * Cloud function that returns information about the used PATs. + */ +export default async (_, res) => { + res.setHeader("Content-Type", "application/json"); + try { + // Add header to prevent abuse. + const PATsInfo = await getPATInfo(uptimeFetcher, {}); + if (PATsInfo) { + res.setHeader( + "Cache-Control", + `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, + ); + } + res.send(JSON.stringify(PATsInfo, null, 2)); + } catch (err) { + // Throw error if something went wrong. + logger.error(err); + res.setHeader("Cache-Control", "no-store"); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/api/status/up.js b/api/status/up.js new file mode 100644 index 00000000..33fe8f90 --- /dev/null +++ b/api/status/up.js @@ -0,0 +1,103 @@ +/** + * @file Contains a simple cloud function that can be used to check if the PATs are still + * functional. + * + * @description This function is currently rate limited to 1 request per 10 minutes. + */ + +import retryer from "../../src/common/retryer.js"; +import { logger, request } from "../../src/common/utils.js"; + +export const RATE_LIMIT_SECONDS = 60 * 10; // 1 request per 10 minutes + +/** + * Simple uptime check fetcher for the PATs. + * + * @param {import('axios').AxiosRequestHeaders} variables + * @param {string} token + */ +const uptimeFetcher = (variables, token) => { + return request( + { + query: ` + query { + rateLimit { + remaining + } + } + `, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); +}; + +/** + * Creates Json response that can be used for shields.io dynamic card generation. + * + * @param {*} up Whether the PATs are up or not. + * @returns Dynamic shields.io JSON response object. + * + * @see https://shields.io/endpoint. + */ +const shieldsUptimeBadge = (up) => { + const schemaVersion = 1; + const isError = true; + const label = "Public Instance"; + const message = up ? "up" : "down"; + const color = up ? "brightgreen" : "red"; + return { + schemaVersion, + label, + message, + color, + isError, + }; +}; + +/** + * Cloud function that returns whether the PATs are still functional. + */ +export default async (req, res) => { + let { type } = req.query; + type = type ? type.toLowerCase() : "boolean"; + + res.setHeader("Content-Type", "application/json"); + + try { + let PATsValid = true; + try { + await retryer(uptimeFetcher, {}); + } catch (err) { + PATsValid = false; + } + + if (PATsValid) { + res.setHeader( + "Cache-Control", + `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, + ); + } else { + res.setHeader("Cache-Control", "no-store"); + } + + switch (type) { + case "shields": + res.send(shieldsUptimeBadge(PATsValid)); + break; + case "json": + res.send({ up: PATsValid }); + break; + default: + res.send(PATsValid); + break; + } + } catch (err) { + // Return fail boolean if something went wrong. + logger.error(err); + res.setHeader("Cache-Control", "no-store"); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/api/top-langs.js b/api/top-langs.js index d183d3b4..e67d9533 100644 --- a/api/top-langs.js +++ b/api/top-langs.js @@ -29,6 +29,8 @@ export default async (req, res) => { locale, border_radius, border_color, + disable_animations, + hide_progress, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); @@ -75,6 +77,8 @@ export default async (req, res) => { border_radius, border_color, locale: locale ? locale.toLowerCase() : null, + disable_animations: parseBoolean(disable_animations), + hide_progress: parseBoolean(hide_progress), }), ); } catch (err) { diff --git a/package-lock.json b/package-lock.json index 048c316b..ebc7570a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@testing-library/dom": "^8.17.1", "@testing-library/jest-dom": "^5.16.5", "@uppercod/css-to-object": "^1.1.1", - "axios-mock-adapter": "^1.18.1", + "axios-mock-adapter": "^1.21.2", "color-contrast-checker": "^2.1.0", "hjson": "^3.2.2", "husky": "^8.0.0", diff --git a/package.json b/package.json index 95b1a11d..606d4f54 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@testing-library/dom": "^8.17.1", "@testing-library/jest-dom": "^5.16.5", "@uppercod/css-to-object": "^1.1.1", - "axios-mock-adapter": "^1.18.1", + "axios-mock-adapter": "^1.21.2", "color-contrast-checker": "^2.1.0", "hjson": "^3.2.2", "husky": "^8.0.0", diff --git a/readme.md b/readme.md index 37e3ecab..d2fb0415 100644 --- a/readme.md +++ b/readme.md @@ -94,7 +94,7 @@ Visit and make a small donation to hel - [Wakatime Card Exclusive Option](#wakatime-card-exclusive-options) - [Wild Cards](#wild-cards) - [Deploy Yourself](#deploy-on-your-own-vercel-instance) - - [Keep your fork up to date](#keep-your-fork-up-to-date) + - [Keep your fork up to date](#keep-your-fork-up-to-date) # GitHub Stats Card @@ -260,12 +260,12 @@ You can customize the appearance of your `Stats Card` or `Repo Card` however you - `bg_color` - Card's background color _(hex color)_ **or** a gradient in the form of _angle,start,end_. Default: `fffefe` - `hide_border` - Hides the card's border _(boolean)_. Default: `false` - `theme` - name of the theme, choose from [all available themes](./themes/README.md). Default: `default` theme. -- `cache_seconds` - set the cache header manually _(min: 7200, max: 86400)_. Default: `14400 seconds (4 hours)`. +- `cache_seconds` - set the cache header manually _(min: 14400, max: 86400)_. Default: `14400 seconds (4 hours)`. - `locale` - set the language in the card _(e.g. cn, de, es, etc.)_. Default: `en`. - `border_radius` - Corner rounding on the card. Default: `4.5`. > **Warning** -> We use caching to decrease the load on our servers (see https://github.com/anuraghazra/github-readme-stats/issues/1471#issuecomment-1271551425). Our cards have a default cache of 4 hours (14400 seconds). Also, note that the cache is clamped to a minimum of 4 hours and a maximum of 24 hours. +> We use caching to decrease the load on our servers (see ). Our cards have a default cache of 4 hours (14400 seconds). Also, note that the cache is clamped to a minimum of 4 hours and a maximum of 24 hours. ##### Gradient in bg_color @@ -305,6 +305,8 @@ You can provide multiple comma-separated values in the bg_color option to render - `langs_count` - Show more languages on the card, between 1-10 _(number)_. Default `5`. - `exclude_repo` - Exclude specified repositories _(Comma-separated values)_. Default: `[] (blank array)`. - `custom_title` - Sets a custom title for the card _(string)_. Default `Most Used Languages`. +- `disable_animations` - Disables all animations in the card _(boolean)_. Default: `false`. +- `hide_progress` - It uses the compact layout option, hides percentages, and removes the bars. Default: `false`. > **Warning** > Language names should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) @@ -354,7 +356,7 @@ Use [show_owner](#customization) variable to include the repo's owner username The top languages card shows a GitHub user's most frequently used top language. > **Note** -> Top Languages does not indicate my skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats._ +> Top Languages does not indicate my skill level or anything like that; it's a GitHub metric to determine which languages have the most code on GitHub. It is a new feature of github-readme-stats. ### Usage @@ -398,6 +400,14 @@ You can use the `&layout=compact` option to change the card design. [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats) ``` +### Hide Progress Bars + +You can use the `&hide_progress=true` option to hide the percentages and the progress bars (layout will be automatically set to `compact`). + +```md +[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats) +``` + ### Demo [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) @@ -406,6 +416,10 @@ You can use the `&layout=compact` option to change the card design. [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats) +- Hidden progress bars + +[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats) + # Wakatime Week Stats Change the `?username=` value to your [Wakatime](https://wakatime.com) username. @@ -556,7 +570,7 @@ Finally, no parameter is required, and parameters that aren't given will default ## Deploy on your own Vercel instance -#### [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107) +#### :film_projector: [Check Out Step By Step Video Tutorial By @codeSTACKr](https://youtu.be/n6d4KHSKqGk?t=107) > **Warning** > If you are on the [hobby (i.e. free)](https://vercel.com/pricing) Vercel plan, please make sure you change the `maxDuration` parameter in the [vercel.json](https://github.com/anuraghazra/github-readme-stats/blob/master/vercel.json) file from `30` to `10` (see [#1416](https://github.com/anuraghazra/github-readme-stats/issues/1416#issuecomment-950275476) for more information). diff --git a/scripts/push-theme-readme.sh b/scripts/push-theme-readme.sh index 4a035db3..1ab5de47 100755 --- a/scripts/push-theme-readme.sh +++ b/scripts/push-theme-readme.sh @@ -9,6 +9,6 @@ git config --global user.name "GitHub Readme Stats Bot" git branch -d $BRANCH_NAME || true git checkout -b $BRANCH_NAME git add --all -git commit --message "docs(theme): Auto update theme readme" || exit 0 +git commit --no-verify --message "docs(theme): Auto update theme readme" git remote add origin-$BRANCH_NAME https://${PERSONAL_TOKEN}@github.com/${GH_REPO}.git git push --force --quiet --set-upstream origin-$BRANCH_NAME $BRANCH_NAME diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index fb3c3dae..5087921f 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -12,6 +12,11 @@ import { import { getStyles } from "../getStyles.js"; import { statCardLocales } from "../translations.js"; +const CARD_MIN_WIDTH = 287; +const CARD_DEFAULT_WIDTH = 287; +const RANK_CARD_MIN_WIDTH = 420; +const RANK_CARD_DEFAULT_WIDTH = 450; + /** * Create a stats card text item. * @@ -235,11 +240,17 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). Numbers are picked by looking at existing dimensions on production. */ - const iconWidth = show_icons ? 16 : 0; - const minCardWidth = hide_rank - ? clampValue(50 /* padding */ + calculateTextWidth() * 2, 270, Infinity) - : 340 + iconWidth; - const defaultCardWidth = hide_rank ? 270 : 495; + const iconWidth = show_icons ? 16 + /* padding */ 1 : 0; + const minCardWidth = + (hide_rank + ? clampValue( + 50 /* padding */ + calculateTextWidth() * 2, + CARD_MIN_WIDTH, + Infinity, + ) + : RANK_CARD_MIN_WIDTH) + iconWidth; + const defaultCardWidth = + (hide_rank ? CARD_DEFAULT_WIDTH : RANK_CARD_DEFAULT_WIDTH) + iconWidth; let width = isNaN(card_width) ? defaultCardWidth : card_width; if (width < minCardWidth) { width = minCardWidth; @@ -268,18 +279,21 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { /** * Calculates the right rank circle translation values such that the rank circle - * keeps respecting the padding. + * keeps respecting the following padding: * - * width > 450: The default left padding of 50 px will be used. - * width < 450: The left and right padding will shrink equally. + * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used. + * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged + * equally from a certain minimum at RANK_CARD_MIN_WIDTH. * * @returns {number} - Rank circle translation value. */ const calculateRankXTranslation = () => { - if (width < 450) { - return width - 95 + (45 * (450 - 340)) / 110; + const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70; + if (width > RANK_CARD_DEFAULT_WIDTH) { + const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2; + return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH; } else { - return width - 95; + return minXTranslation + (width - minCardWidth) / 2; } }; diff --git a/src/cards/top-languages-card.js b/src/cards/top-languages-card.js index 1c1c89f1..852341cc 100644 --- a/src/cards/top-languages-card.js +++ b/src/cards/top-languages-card.js @@ -39,49 +39,58 @@ const getLongestLang = (arr) => * Creates a node to display usage of a programming language in percentage * using text and a horizontal progress bar. * - * @param {object[]} props Function properties. + * @param {object} props Function properties. * @param {number} props.width The card width * @param {string} props.name Name of the programming language. * @param {string} props.color Color of the programming language. * @param {string} props.progress Usage of the programming language in percentage. + * @param {number} props.index Index of the programming language. * @returns {string} Programming language SVG node. */ -const createProgressTextNode = ({ width, color, name, progress, text }) => { + +const createProgressTextNode = ({ width, color, name, progress, index }) => { + const staggerDelay = (index + 3) * 150; const paddingRight = 95; const progressTextX = width - paddingRight + 10; const progressWidth = width - paddingRight; return ` - ${name} - ${text || `${progress}%`} - ${createProgressNode({ - x: 0, - y: 25, - color, - width: progressWidth, - progress, - progressBarBackgroundColor: "#ddd", - })} + + ${name} + ${text || `${progress}%`} + ${createProgressNode({ + x: 0, + y: 25, + color, + width: progressWidth, + progress, + progressBarBackgroundColor: "#ddd", + delay: staggerDelay + 300, + })} + `; }; /** * Creates a text only node to display usage of a programming language in percentage. * - * @param {object[]} props Function properties. + * @param {object} props Function properties. * @param {Lang} props.lang Programming language object. * @param {number} props.totalSize Total size of all languages. + * @param {boolean} props.hideProgress Whether to hide percentage. + * @param {number} props.index Index of the programming language. * @returns {string} Compact layout programming language SVG node. */ -const createCompactLangNode = ({ lang, totalSize }) => { +const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => { const percentage = ((lang.size / totalSize) * 100).toFixed(2); + const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; return ` - + - ${lang.name} ${lang.text || `${percentage}%`} + ${lang.name} ${hideProgress ? "" : (lang.text || `${percentage}%`)} `; @@ -93,9 +102,10 @@ const createCompactLangNode = ({ lang, totalSize }) => { * @param {object[]} props Function properties. * @param {Lang[]} props.langs Array of programming languages. * @param {number} props.totalSize Total size of all languages. + * @param {boolean} props.hideProgress Whether to hide percentage. * @returns {string} Programming languages SVG node. */ -const createLanguageTextNode = ({ langs, totalSize }) => { +const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => { const longestLang = getLongestLang(langs); const chunked = chunkArray(langs, langs.length / 2); const layouts = chunked.map((array) => { @@ -104,7 +114,7 @@ const createLanguageTextNode = ({ langs, totalSize }) => { createCompactLangNode({ lang, totalSize, - // @ts-ignore + hideProgress, index, }), ); @@ -134,13 +144,14 @@ const createLanguageTextNode = ({ langs, totalSize }) => { */ const renderNormalLayout = (langs, width, totalLanguageSize) => { return flexLayout({ - items: langs.map((lang) => { + items: langs.map((lang, index) => { return createProgressTextNode({ - width: width, + width, name: lang.name, color: lang.color || DEFAULT_LANG_COLOR, progress: ((lang.size / totalLanguageSize) * 100).toFixed(2), text: lang.text, + index, }); }), gap: 40, @@ -154,9 +165,10 @@ const renderNormalLayout = (langs, width, totalLanguageSize) => { * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. + * @param {boolean} hideProgress Whether to hide progress bar. * @returns {string} Compact layout card SVG object. */ -const renderCompactLayout = (langs, width, totalLanguageSize) => { +const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => { const paddingRight = 50; const offsetWidth = width - paddingRight; // progressOffset holds the previous language's width and used to offset the next language @@ -187,15 +199,21 @@ const renderCompactLayout = (langs, width, totalLanguageSize) => { .join(""); return ` - - + ${ + !hideProgress + ? ` + + ${compactProgressBar} - - + ` + : "" + } + ${createLanguageTextNode({ langs, totalSize: totalLanguageSize, + hideProgress: hideProgress, })} `; @@ -276,6 +294,7 @@ const renderTopLanguages = (topLangs, options = {}) => { text_color, bg_color, hide, + hide_progress, theme, layout, custom_title, @@ -283,6 +302,7 @@ const renderTopLanguages = (topLangs, options = {}) => { border_radius, border_color, title = i18n.t("langcard.title"), + disable_animations, } = options; const { langs, totalLanguageSize } = useLanguages( @@ -299,11 +319,17 @@ const renderTopLanguages = (topLangs, options = {}) => { let height = calculateNormalLayoutHeight(langs.length); let finalLayout = ""; - if (layout === "compact") { + if (layout === "compact" || hide_progress == true) { width = width + 50; // padding - height = calculateCompactLayoutHeight(langs.length); + height = + calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0); - finalLayout = renderCompactLayout(langs, width, totalLanguageSize); + finalLayout = renderCompactLayout( + langs, + width, + totalLanguageSize, + hide_progress, + ); } else { finalLayout = renderNormalLayout(langs, width, totalLanguageSize); } @@ -326,11 +352,43 @@ const renderTopLanguages = (topLangs, options = {}) => { colors, }); - card.disableAnimations(); + if (disable_animations) card.disableAnimations(); + card.setHideBorder(hide_border); card.setHideTitle(hide_title); card.setCSS( - `.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }`, + ` + @keyframes slideInAnimation { + from { + width: 0; + } + to { + width: calc(100%-100px); + } + } + @keyframes growWidthAnimation { + from { + width: 0; + } + to { + width: 100%; + } + } + .lang-name { + font: 400 11px "Segoe UI", Ubuntu, Sans-Serif; + fill: ${colors.textColor}; + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + #rect-mask rect{ + animation: slideInAnimation 1s ease-in-out forwards; + } + .lang-progress{ + animation: growWidthAnimation 0.6s ease-in-out forwards; + } + `, ); return card.render(` diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index 502314c4..52ee0edb 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -37,6 +37,8 @@ export type TopLangOptions = CommonOptions & { layout: "compact" | "normal"; custom_title: string; langs_count: number; + disable_animations: boolean; + hide_progress: boolean; }; type WakaTimeOptions = CommonOptions & { diff --git a/src/common/createProgressNode.js b/src/common/createProgressNode.js index c36818b1..2825583c 100644 --- a/src/common/createProgressNode.js +++ b/src/common/createProgressNode.js @@ -10,6 +10,7 @@ import { clampValue } from "./utils.js"; * @param {string} createProgressNodeParams.color Progress color. * @param {string} createProgressNodeParams.progress Progress value. * @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color. + * @param {number} createProgressNodeParams.delay Delay before animation starts. * @returns {string} Progress node. */ const createProgressNode = ({ @@ -19,20 +20,22 @@ const createProgressNode = ({ color, progress, progressBarBackgroundColor, + delay, }) => { const progressPercentage = clampValue(progress, 2, 100); return ` - - + + + `; }; diff --git a/src/common/languageColors.json b/src/common/languageColors.json index 7e8cd551..47bfb1cf 100644 --- a/src/common/languageColors.json +++ b/src/common/languageColors.json @@ -102,6 +102,7 @@ "Csound Score": "#1a1a1a", "Cuda": "#3A4E3A", "Curry": "#531242", + "Cypher": "#34c0eb", "Cython": "#fedf5b", "D": "#ba595e", "DM": "#447265", @@ -124,6 +125,7 @@ "Earthly": "#2af0ff", "Easybuild": "#069406", "Ecere Projects": "#913960", + "Ecmarkup": "#eb8131", "EditorConfig": "#fff1f2", "Eiffel": "#4d6977", "Elixir": "#6e4a7e", @@ -215,6 +217,7 @@ "Idris": "#b30000", "Ignore List": "#000000", "ImageJ Macro": "#99AAFF", + "Imba": "#16cec6", "Inno Setup": "#264b99", "Io": "#a9188d", "Ioke": "#078193", @@ -286,6 +289,7 @@ "Mathematica": "#dd1100", "Max": "#c4a79c", "Mercury": "#ff2b2b", + "Mermaid": "#ff3670", "Meson": "#007800", "Metal": "#8f14e9", "MiniYAML": "#ff1111", @@ -318,6 +322,10 @@ "Nu": "#c9df40", "NumPy": "#9C8AF9", "Nunjucks": "#3d8137", + "OASv2-json": "#85ea2d", + "OASv2-yaml": "#85ea2d", + "OASv3-json": "#85ea2d", + "OASv3-yaml": "#85ea2d", "OCaml": "#3be133", "ObjectScript": "#424893", "Objective-C": "#438eff", @@ -327,14 +335,18 @@ "Omgrofl": "#cabbff", "Opal": "#f7ede0", "Open Policy Agent": "#7d9199", + "OpenAPI Specification v2": "#85ea2d", + "OpenAPI Specification v3": "#85ea2d", "OpenCL": "#ed2e2d", "OpenEdge ABL": "#5ce600", "OpenQASM": "#AA70FF", "OpenSCAD": "#e5cd45", + "Option List": "#476732", "Org": "#77aa99", "Oxygene": "#cdd0e3", "Oz": "#fab738", "P4": "#7055b5", + "PDDL": "#0d00ff", "PEG.js": "#234d6b", "PHP": "#4F5D95", "PLSQL": "#dad8d8", @@ -351,6 +363,7 @@ "PigLatin": "#fcd7de", "Pike": "#005390", "PogoScript": "#d80074", + "Polar": "#ae81ff", "Portugol": "#f8bd00", "PostCSS": "#dc3a0c", "PostScript": "#da291c", @@ -414,6 +427,7 @@ "Sass": "#a53b70", "Scala": "#c22d40", "Scaml": "#bd181a", + "Scenic": "#fdc700", "Scheme": "#1e4aec", "Scilab": "#ca0f21", "Self": "#0579aa", @@ -421,6 +435,7 @@ "Shell": "#89e051", "ShellCheck Config": "#cecfcb", "Shen": "#120F14", + "Simple File Verification": "#C9BFED", "Singularity": "#64E6AD", "Slash": "#007eff", "Slice": "#003fa2", @@ -428,6 +443,7 @@ "SmPL": "#c94949", "Smalltalk": "#596706", "Smarty": "#f0c040", + "Smithy": "#c44536", "Solidity": "#AA6746", "SourcePawn": "#f69e1d", "Squirrel": "#800000", @@ -478,6 +494,7 @@ "Vim Script": "#199f4b", "Vim Snippet": "#199f4b", "Visual Basic .NET": "#945db7", + "Visual Basic 6.0": "#2c6353", "Volt": "#1F1F1F", "Vue": "#41b883", "Vyper": "#2980b9", @@ -514,6 +531,7 @@ "fish": "#4aae47", "hoon": "#00b171", "jq": "#c7254e", + "just": "#384d54", "kvlang": "#1da6e0", "mIRC Script": "#3d57c3", "mcfunction": "#E22837", diff --git a/src/common/retryer.js b/src/common/retryer.js index 73833ef8..5351cbe8 100644 --- a/src/common/retryer.js +++ b/src/common/retryer.js @@ -45,7 +45,9 @@ const retryer = async (fetcher, variables, retries = 0) => { // prettier-ignore // also checking for bad credentials if any tokens gets invalidated const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; - const isAccountSuspended = err.response.data && err.response.data.message === "Sorry. Your account was suspended."; + const isAccountSuspended = + err.response.data && + err.response.data.message === "Sorry. Your account was suspended."; if (isBadCredential || isAccountSuspended) { logger.log(`PAT_${retries + 1} Failed`); diff --git a/src/common/utils.js b/src/common/utils.js index 1215fc9a..c600c717 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -424,6 +424,19 @@ const parseEmojis = (str) => { }); }; +/** + * Get diff in minutes + * @param {Date} d1 + * @param {Date} d2 + * @returns {number} + */ +const dateDiff = (d1, d2) => { + const date1 = new Date(d1); + const date2 = new Date(d2); + const diff = date1.getTime() - date2.getTime(); + return Math.round(diff / (1000 * 60)); +}; + export { ERROR_CARD_LENGTH, renderError, @@ -447,4 +460,5 @@ export { lowercaseTrim, chunkArray, parseEmojis, + dateDiff, }; diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 7f6cb9e5..a7df1e50 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -1,5 +1,6 @@ // @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"; @@ -11,46 +12,74 @@ import { wrapTextMultiline, } from "../common/utils.js"; +dotenv.config(); + +// GraphQL queries. +const GRAPHQL_REPOS_FIELD = ` + repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { + totalCount + nodes { + name + stargazers { + totalCount + } + } + pageInfo { + hasNextPage + endCursor + } + } +`; + +const GRAPHQL_REPOS_QUERY = ` + query userInfo($login: String!, $after: String) { + user(login: $login) { + ${GRAPHQL_REPOS_FIELD} + } + } +`; + +const GRAPHQL_STATS_QUERY = ` + query userInfo($login: String!, $after: String) { + user(login: $login) { + name + login + contributionsCollection { + totalCommitContributions + restrictedContributionsCount + } + repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { + totalCount + } + pullRequests(first: 1) { + totalCount + } + openIssues: issues(states: OPEN) { + totalCount + } + closedIssues: issues(states: CLOSED) { + totalCount + } + followers { + totalCount + } + ${GRAPHQL_REPOS_FIELD} + } + } +`; + /** * Stats fetcher object. * * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. * @param {string} token GitHub token. - * @returns {Promise} Stats fetcher response. + * @returns {Promise} Stats fetcher response. */ const fetcher = (variables, token) => { + const query = !variables.after ? GRAPHQL_STATS_QUERY : GRAPHQL_REPOS_QUERY; return request( { - query: ` - query userInfo($login: String!) { - user(login: $login) { - name - login - contributionsCollection { - totalCommitContributions - restrictedContributionsCount - } - repositoriesContributedTo(contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { - totalCount - } - pullRequests { - totalCount - } - openIssues: issues(states: OPEN) { - totalCount - } - closedIssues: issues(states: CLOSED) { - totalCount - } - followers { - totalCount - } - repositories(ownerAffiliations: OWNER) { - totalCount - } - } - } - `, + query, variables, }, { @@ -60,39 +89,42 @@ const fetcher = (variables, token) => { }; /** - * Fetch first 100 repositories for a given username. + * Fetch stats information for a given username. * - * @param {import('axios').AxiosRequestHeaders} variables Fetcher variables. - * @param {string} token GitHub token. - * @returns {Promise} Repositories fetcher response. + * @param {string} username Github username. + * @returns {Promise} GraphQL Stats object. + * + * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true. */ -const repositoriesFetcher = (variables, token) => { - return request( - { - query: ` - query userInfo($login: String!, $after: String) { - user(login: $login) { - repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}, after: $after) { - nodes { - name - stargazers { - totalCount - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - `, - variables, - }, - { - Authorization: `bearer ${token}`, - }, - ); +const statsFetcher = async (username) => { + let stats; + let hasNextPage = true; + let endCursor = null; + while (hasNextPage) { + const variables = { login: username, first: 100, after: endCursor }; + 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 = res; + } else { + stats.data.data.user.repositories.nodes.push(...repoNodes); + } + + // 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; }; /** @@ -137,46 +169,6 @@ const totalCommitsFetcher = async (username) => { return 0; }; -/** - * Fetch all the stars for all the repositories of a given username. - * - * @param {string} username GitHub username. - * @param {array} repoToHide Repositories to hide. - * @returns {Promise} Total stars. - */ -const totalStarsFetcher = async (username, repoToHide) => { - let nodes = []; - let hasNextPage = true; - let endCursor = null; - while (hasNextPage) { - const variables = { login: username, first: 100, after: endCursor }; - let res = await retryer(repositoriesFetcher, variables); - - if (res.data.errors) { - logger.error(res.data.errors); - throw new CustomError( - res.data.errors[0].message || "Could not fetch user", - CustomError.USER_NOT_FOUND, - ); - } - - const allNodes = res.data.data.user.repositories.nodes; - const nodesWithStars = allNodes.filter( - (node) => node.stargazers.totalCount !== 0, - ); - nodes.push(...nodesWithStars); - // hasNextPage = - // allNodes.length === nodesWithStars.length && - // res.data.data.user.repositories.pageInfo.hasNextPage; - hasNextPage = false; // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - endCursor = res.data.data.user.repositories.pageInfo.endCursor; - } - - return nodes - .filter((data) => !repoToHide[data.name]) - .reduce((prev, curr) => prev + curr.stargazers.totalCount, 0); -}; - /** * Fetch stats for a given username. * @@ -203,7 +195,7 @@ const fetchStats = async ( rank: { level: "C", score: 0 }, }; - let res = await retryer(fetcher, { login: username }); + let res = await statsFetcher(username); // Catch GraphQL errors. if (res.data.errors) { @@ -259,8 +251,15 @@ const fetchStats = async ( stats.contributedTo = user.repositoriesContributedTo.totalCount; // Retrieve stars while filtering out repositories to be hidden - stats.totalStars = await totalStarsFetcher(username, repoToHide); + stats.totalStars = user.repositories.nodes + .filter((data) => { + return !repoToHide[data.name]; + }) + .reduce((prev, curr) => { + return prev + curr.stargazers.totalCount; + }, 0); + // @ts-ignore // TODO: Fix this. stats.rank = calculateRank({ totalCommits: stats.totalCommits, totalRepos: user.repositories.totalCount, diff --git a/tests/api.test.js b/tests/api.test.js index 0037bcdb..461f3e18 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -25,7 +25,7 @@ stats.rank = calculateRank({ issues: stats.totalIssues, }); -const data = { +const data_stats = { data: { user: { name: stats.name, @@ -40,15 +40,6 @@ const data = { followers: { totalCount: 0 }, repositories: { totalCount: 1, - }, - }, - }, -}; - -const repositoriesData = { - data: { - user: { - repositories: { nodes: [{ stargazers: { totalCount: 100 } }], pageInfo: { hasNextPage: false, @@ -83,11 +74,7 @@ const faker = (query, data) => { setHeader: jest.fn(), send: jest.fn(), }; - mock - .onPost("https://api.github.com/graphql") - .replyOnce(200, data) - .onPost("https://api.github.com/graphql") - .replyOnce(200, repositoriesData); + mock.onPost("https://api.github.com/graphql").replyOnce(200, data); return { req, res }; }; @@ -98,7 +85,7 @@ afterEach(() => { describe("Test /api/", () => { it("should test the request", async () => { - const { req, res } = faker({}, data); + const { req, res } = faker({}, data_stats); await api(req, res); @@ -133,7 +120,7 @@ describe("Test /api/", () => { text_color: "fff", bg_color: "fff", }, - data, + data_stats, ); await api(req, res); @@ -154,7 +141,7 @@ describe("Test /api/", () => { }); it("should have proper cache", async () => { - const { req, res } = faker({}, data); + const { req, res } = faker({}, data_stats); await api(req, res); @@ -170,7 +157,7 @@ describe("Test /api/", () => { }); it("should set proper cache", async () => { - const { req, res } = faker({ cache_seconds: 15000 }, data); + const { req, res } = faker({ cache_seconds: 15000 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ @@ -196,7 +183,7 @@ describe("Test /api/", () => { it("should set proper cache with clamped values", async () => { { - let { req, res } = faker({ cache_seconds: 200000 }, data); + let { req, res } = faker({ cache_seconds: 200000 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ @@ -212,7 +199,7 @@ describe("Test /api/", () => { // note i'm using block scoped vars { - let { req, res } = faker({ cache_seconds: 0 }, data); + let { req, res } = faker({ cache_seconds: 0 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ @@ -227,7 +214,7 @@ describe("Test /api/", () => { } { - let { req, res } = faker({ cache_seconds: -10000 }, data); + let { req, res } = faker({ cache_seconds: -10000 }, data_stats); await api(req, res); expect(res.setHeader.mock.calls).toEqual([ @@ -248,7 +235,7 @@ describe("Test /api/", () => { username: "anuraghazra", count_private: true, }, - data, + data_stats, ); await api(req, res); @@ -288,7 +275,7 @@ describe("Test /api/", () => { text_color: "fff", bg_color: "fff", }, - data, + data_stats, ); await api(req, res); diff --git a/tests/e2e/e2e.test.js b/tests/e2e/e2e.test.js index 48bf16a0..402e210f 100644 --- a/tests/e2e/e2e.test.js +++ b/tests/e2e/e2e.test.js @@ -11,27 +11,22 @@ import { renderStatsCard } from "../../src/cards/stats-card.js"; import { renderTopLanguages } from "../../src/cards/top-languages-card.js"; import { renderWakatimeCard } from "../../src/cards/wakatime-card.js"; -const REPO = "dummy-cra"; -const USER = "grsdummy"; +const REPO = "curly-fiesta"; +const USER = "catelinemnemosyne"; const STATS_DATA = { - name: "grsdummy", - totalPRs: 2, - totalCommits: 2, + name: "Cateline Mnemosyne", + totalPRs: 1, + totalCommits: 7, totalIssues: 1, totalStars: 1, - contributedTo: 2, + contributedTo: 1, rank: { level: "A+", - score: 50.900829325065935, + score: 50.893750297869225, }, }; const LANGS_DATA = { - TypeScript: { - color: "#3178c6", - name: "TypeScript", - size: 2049, - }, HTML: { color: "#e34c26", name: "HTML", @@ -42,19 +37,19 @@ const LANGS_DATA = { name: "CSS", size: 930, }, - Python: { - color: "#3572A5", - name: "Python", - size: 671, + JavaScript: { + color: "#f1e05a", + name: "JavaScript", + size: 1912, }, }; const WAKATIME_DATA = { human_readable_range: "last week", is_already_updating: false, - is_coding_activity_visible: false, + is_coding_activity_visible: true, is_including_today: false, - is_other_usage_visible: false, + is_other_usage_visible: true, is_stuck: false, is_up_to_date: false, is_up_to_date_pending_future: false, @@ -62,24 +57,24 @@ const WAKATIME_DATA = { range: "last_7_days", status: "pending_update", timeout: 15, - username: "grsdummy", + username: USER, writes_only: false, }; const REPOSITORY_DATA = { - name: "dummy-cra", - nameWithOwner: "grsdummy/dummy-cra", + name: REPO, + nameWithOwner: `${USER}/cra-test`, isPrivate: false, isArchived: false, isTemplate: false, stargazers: { totalCount: 1, }, - description: "Dummy create react app.", + description: "Simple cra test repo.", primaryLanguage: { - color: "#3178c6", - id: "MDg6TGFuZ3VhZ2UyODc=", - name: "TypeScript", + color: "#f1e05a", + id: "MDg6TGFuZ3VhZ2UxNDA=", + name: "JavaScript", }, forkCount: 0, starCount: 1, diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js index 192146ea..04e943a7 100644 --- a/tests/fetchStats.test.js +++ b/tests/fetchStats.test.js @@ -4,7 +4,8 @@ import MockAdapter from "axios-mock-adapter"; import { calculateRank } from "../src/calculateRank.js"; import { fetchStats } from "../src/fetchers/stats-fetcher.js"; -const data = { +// Test parameters. +const data_stats = { data: { user: { name: "Anurag Hazra", @@ -19,15 +20,6 @@ const data = { followers: { totalCount: 100 }, repositories: { totalCount: 5, - }, - }, - }, -}; - -const firstRepositoriesData = { - data: { - user: { - repositories: { nodes: [ { name: "test-repo-1", stargazers: { totalCount: 100 } }, { name: "test-repo-2", stargazers: { totalCount: 100 } }, @@ -42,7 +34,7 @@ const firstRepositoriesData = { }, }; -const secondRepositoriesData = { +const data_repo = { data: { user: { repositories: { @@ -59,7 +51,7 @@ const secondRepositoriesData = { }, }; -const repositoriesWithZeroStarsData = { +const data_repo_zero_stars = { data: { user: { repositories: { @@ -93,13 +85,12 @@ const error = { const mock = new MockAdapter(axios); beforeEach(() => { + process.env.FETCH_MULTI_PAGE_STARS = "false"; // Set to `false` to fetch only one page of stars. mock .onPost("https://api.github.com/graphql") - .replyOnce(200, data) + .replyOnce(200, data_stats) .onPost("https://api.github.com/graphql") - .replyOnce(200, firstRepositoriesData); - // .onPost("https://api.github.com/graphql") // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - // .replyOnce(200, secondRepositoriesData); // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + .replyOnce(200, data_repo); }); afterEach(() => { @@ -114,8 +105,7 @@ describe("Test fetchStats", () => { totalRepos: 5, followers: 100, contributions: 61, - // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + stargazers: 300, prs: 300, issues: 200, }); @@ -126,8 +116,7 @@ describe("Test fetchStats", () => { totalCommits: 100, totalIssues: 200, totalPRs: 300, - // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + totalStars: 300, rank, }); }); @@ -136,9 +125,9 @@ describe("Test fetchStats", () => { mock.reset(); mock .onPost("https://api.github.com/graphql") - .replyOnce(200, data) + .replyOnce(200, data_stats) .onPost("https://api.github.com/graphql") - .replyOnce(200, repositoriesWithZeroStarsData); + .replyOnce(200, data_repo_zero_stars); let stats = await fetchStats("anuraghazra"); const rank = calculateRank({ @@ -178,8 +167,7 @@ describe("Test fetchStats", () => { totalRepos: 5, followers: 100, contributions: 61, - // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + stargazers: 300, prs: 300, issues: 200, }); @@ -190,8 +178,7 @@ describe("Test fetchStats", () => { totalCommits: 150, totalIssues: 200, totalPRs: 300, - // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + totalStars: 300, rank, }); }); @@ -207,8 +194,7 @@ describe("Test fetchStats", () => { totalRepos: 5, followers: 100, contributions: 61, - // stargazers: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + stargazers: 300, prs: 300, issues: 200, }); @@ -219,8 +205,7 @@ describe("Test fetchStats", () => { totalCommits: 1050, totalIssues: 200, totalPRs: 300, - // totalStars: 400, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + totalStars: 300, rank, }); }); @@ -236,8 +221,7 @@ describe("Test fetchStats", () => { totalRepos: 5, followers: 100, contributions: 61, - // stargazers: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - stargazers: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + stargazers: 200, prs: 300, issues: 200, }); @@ -248,8 +232,82 @@ describe("Test fetchStats", () => { totalCommits: 1050, totalIssues: 200, totalPRs: 300, - // totalStars: 300, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. - totalStars: 200, // NOTE: Temporarily disable fetching of multiple pages. Done because of #2130. + totalStars: 200, + rank, + }); + }); + + it("should fetch two pages of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `true`", async () => { + process.env.FETCH_MULTI_PAGE_STARS = true; + + let stats = await fetchStats("anuraghazra"); + const rank = calculateRank({ + totalCommits: 100, + totalRepos: 5, + followers: 100, + contributions: 61, + stargazers: 400, + prs: 300, + issues: 200, + }); + + expect(stats).toStrictEqual({ + contributedTo: 61, + name: "Anurag Hazra", + totalCommits: 100, + totalIssues: 200, + totalPRs: 300, + totalStars: 400, + rank, + }); + }); + + it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is set to `false`", async () => { + process.env.FETCH_MULTI_PAGE_STARS = "false"; + + let stats = await fetchStats("anuraghazra"); + const rank = calculateRank({ + totalCommits: 100, + totalRepos: 5, + followers: 100, + contributions: 61, + stargazers: 300, + prs: 300, + issues: 200, + }); + + expect(stats).toStrictEqual({ + contributedTo: 61, + name: "Anurag Hazra", + totalCommits: 100, + totalIssues: 200, + totalPRs: 300, + totalStars: 300, + rank, + }); + }); + + it("should fetch one page of stars if 'FETCH_MULTI_PAGE_STARS' env variable is not set", async () => { + process.env.FETCH_MULTI_PAGE_STARS = undefined; + + let stats = await fetchStats("anuraghazra"); + const rank = calculateRank({ + totalCommits: 100, + totalRepos: 5, + followers: 100, + contributions: 61, + stargazers: 300, + prs: 300, + issues: 200, + }); + + expect(stats).toStrictEqual({ + contributedTo: 61, + name: "Anurag Hazra", + totalCommits: 100, + totalIssues: 200, + totalPRs: 300, + totalStars: 300, rank, }); }); diff --git a/tests/pat-info.test.js b/tests/pat-info.test.js new file mode 100644 index 00000000..23aca8c4 --- /dev/null +++ b/tests/pat-info.test.js @@ -0,0 +1,244 @@ +/** + * @file Tests for the status/pat-info cloud function. + */ +import dotenv from "dotenv"; +dotenv.config(); + +import { jest } from "@jest/globals"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js"; + +const mock = new MockAdapter(axios); + +const successData = { + data: { + rateLimit: { + remaining: 4986, + }, + }, +}; + +const faker = (query) => { + const req = { + query: { ...query }, + }; + const res = { + setHeader: jest.fn(), + send: jest.fn(), + }; + + return { req, res }; +}; + +const rate_limit_error = { + errors: [ + { + type: "RATE_LIMITED", + message: "API rate limit exceeded for user ID.", + }, + ], + data: { + rateLimit: { + resetAt: Date.now(), + }, + }, +}; + +const other_error = { + errors: [ + { + type: "SOME_ERROR", + message: "This is a error", + }, + ], +}; + +const bad_credentials_error = { + message: "Bad credentials", +}; + +afterEach(() => { + mock.reset(); +}); + +describe("Test /api/status/pat-info", () => { + beforeAll(() => { + // reset patenv first so that dotenv doesn't populate them with local envs + process.env = {}; + process.env.PAT_1 = "testPAT1"; + process.env.PAT_2 = "testPAT2"; + process.env.PAT_3 = "testPAT3"; + process.env.PAT_4 = "testPAT4"; + }); + + it("should return only 'validPATs' if all PATs are valid", async () => { + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, rate_limit_error) + .onPost("https://api.github.com/graphql") + .reply(200, successData); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith( + JSON.stringify( + { + validPATs: ["PAT_2", "PAT_3", "PAT_4"], + expiredPATs: [], + exhaustedPATs: ["PAT_1"], + suspendedPATs: [], + errorPATs: [], + details: { + PAT_1: { + status: "exhausted", + remaining: 0, + resetIn: "0 minutes", + }, + PAT_2: { + status: "valid", + remaining: 4986, + }, + PAT_3: { + status: "valid", + remaining: 4986, + }, + PAT_4: { + status: "valid", + remaining: 4986, + }, + }, + }, + null, + 2, + ), + ); + }); + + it("should return `errorPATs` if a PAT causes an error to be thrown", async () => { + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, other_error) + .onPost("https://api.github.com/graphql") + .reply(200, successData); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith( + JSON.stringify( + { + validPATs: ["PAT_2", "PAT_3", "PAT_4"], + expiredPATs: [], + exhaustedPATs: [], + suspendedPATs: [], + errorPATs: ["PAT_1"], + details: { + PAT_1: { + status: "error", + error: { + type: "SOME_ERROR", + message: "This is a error", + }, + }, + PAT_2: { + status: "valid", + remaining: 4986, + }, + PAT_3: { + status: "valid", + remaining: 4986, + }, + PAT_4: { + status: "valid", + remaining: 4986, + }, + }, + }, + null, + 2, + ), + ); + }); + + it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => { + mock + .onPost("https://api.github.com/graphql") + .replyOnce(404, bad_credentials_error) + .onPost("https://api.github.com/graphql") + .reply(200, successData); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith( + JSON.stringify( + { + validPATs: ["PAT_2", "PAT_3", "PAT_4"], + expiredPATs: ["PAT_1"], + exhaustedPATs: [], + suspendedPATs: [], + errorPATs: [], + details: { + PAT_1: { + status: "expired", + }, + PAT_2: { + status: "valid", + remaining: 4986, + }, + PAT_3: { + status: "valid", + remaining: 4986, + }, + PAT_4: { + status: "valid", + remaining: 4986, + }, + }, + }, + null, + 2, + ), + ); + }); + + it("should throw an error if something goes wrong", async () => { + mock.onPost("https://api.github.com/graphql").networkError(); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith("Something went wrong: Network Error"); + }); + + it("should have proper cache when no error is thrown", async () => { + mock.onPost("https://api.github.com/graphql").reply(200, successData); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader.mock.calls).toEqual([ + ["Content-Type", "application/json"], + ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], + ]); + }); + + it("should have proper cache when error is thrown", async () => { + mock.reset(); + mock.onPost("https://api.github.com/graphql").networkError(); + + const { req, res } = faker({}, {}); + await patInfo(req, res); + + expect(res.setHeader.mock.calls).toEqual([ + ["Content-Type", "application/json"], + ["Cache-Control", "no-store"], + ]); + }); +}); diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js index 5afb1f02..748b7a32 100644 --- a/tests/renderStatsCard.test.js +++ b/tests/renderStatsCard.test.js @@ -78,16 +78,17 @@ describe("Test renderStatsCard", () => { it("should render with custom width set", () => { document.body.innerHTML = renderStatsCard(stats); - expect(document.querySelector("svg")).toHaveAttribute("width", "495"); + expect(document.querySelector("svg")).toHaveAttribute("width", "450"); - document.body.innerHTML = renderStatsCard(stats, { card_width: 400 }); - expect(document.querySelector("svg")).toHaveAttribute("width", "400"); + document.body.innerHTML = renderStatsCard(stats, { card_width: 500 }); + expect(document.querySelector("svg")).toHaveAttribute("width", "500"); }); it("should render with custom width set and limit minimum width", () => { document.body.innerHTML = renderStatsCard(stats, { card_width: 1 }); - expect(document.querySelector("svg")).toHaveAttribute("width", "340"); + expect(document.querySelector("svg")).toHaveAttribute("width", "420"); + // Test default minimum card width without rank circle. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: true, @@ -97,6 +98,7 @@ describe("Test renderStatsCard", () => { "305.81250000000006", ); + // Test minimum card width with rank and icons. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: true, @@ -104,22 +106,24 @@ describe("Test renderStatsCard", () => { }); expect(document.querySelector("svg")).toHaveAttribute( "width", - "305.81250000000006", + "322.81250000000006", ); + // Test minimum card width with icons but without rank. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: false, show_icons: true, }); - expect(document.querySelector("svg")).toHaveAttribute("width", "356"); + expect(document.querySelector("svg")).toHaveAttribute("width", "437"); + // Test minimum card width without icons or rank. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: false, show_icons: false, }); - expect(document.querySelector("svg")).toHaveAttribute("width", "340"); + expect(document.querySelector("svg")).toHaveAttribute("width", "420"); }); it("should render default colors properly", () => { @@ -312,7 +316,7 @@ describe("Test renderStatsCard", () => { expect( document.body.getElementsByTagName("svg")[0].getAttribute("width"), - ).toBe("270"); + ).toBe("287"); }); it("should render translations", () => { diff --git a/tests/status.up.test.js b/tests/status.up.test.js new file mode 100644 index 00000000..7cf0144b --- /dev/null +++ b/tests/status.up.test.js @@ -0,0 +1,194 @@ +/** + * @file Tests for the status/up cloud function. + */ +import { jest } from "@jest/globals"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import up, { RATE_LIMIT_SECONDS } from "../api/status/up.js"; + +const mock = new MockAdapter(axios); + +const successData = { + rateLimit: { + remaining: 4986, + }, +}; + +const faker = (query) => { + const req = { + query: { ...query }, + }; + const res = { + setHeader: jest.fn(), + send: jest.fn(), + }; + + return { req, res }; +}; + +const rate_limit_error = { + errors: [ + { + type: "RATE_LIMITED", + }, + ], +}; + +const bad_credentials_error = { + message: "Bad credentials", +}; + +const shields_up = { + schemaVersion: 1, + label: "Public Instance", + isError: true, + message: "up", + color: "brightgreen", +}; +const shields_down = { + schemaVersion: 1, + label: "Public Instance", + isError: true, + message: "down", + color: "red", +}; + +afterEach(() => { + mock.reset(); +}); + +describe("Test /api/status/up", () => { + it("should return `true` if request was successful", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(true); + }); + + it("should return `false` if all PATs are rate limited", async () => { + mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(false); + }); + + it("should return JSON `true` if request was successful and type='json'", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); + + const { req, res } = faker({ type: "json" }, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith({ up: true }); + }); + + it("should return JSON `false` if all PATs are rate limited and type='json'", async () => { + mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); + + const { req, res } = faker({ type: "json" }, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith({ up: false }); + }); + + it("should return UP shields.io config if request was successful and type='shields'", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); + + const { req, res } = faker({ type: "shields" }, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(shields_up); + }); + + it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => { + mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error); + + const { req, res } = faker({ type: "shields" }, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(shields_down); + }); + + it("should return `true` if the first PAT is rate limited but the second PATs works", async () => { + mock + .onPost("https://api.github.com/graphql") + .replyOnce(200, rate_limit_error) + .onPost("https://api.github.com/graphql") + .replyOnce(200, successData); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(true); + }); + + it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => { + mock + .onPost("https://api.github.com/graphql") + .replyOnce(404, bad_credentials_error) + .onPost("https://api.github.com/graphql") + .replyOnce(200, successData); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(true); + }); + + it("should return `false` if all pats have 'Bad credentials'", async () => { + mock + .onPost("https://api.github.com/graphql") + .reply(404, bad_credentials_error); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(false); + }); + + it("should throw an error if the request fails", async () => { + mock.onPost("https://api.github.com/graphql").networkError(); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader).toBeCalledWith("Content-Type", "application/json"); + expect(res.send).toBeCalledWith(false); + }); + + it("should have proper cache when no error is thrown", async () => { + mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader.mock.calls).toEqual([ + ["Content-Type", "application/json"], + ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`], + ]); + }); + + it("should have proper cache when error is thrown", async () => { + mock.onPost("https://api.github.com/graphql").networkError(); + + const { req, res } = faker({}, {}); + await up(req, res); + + expect(res.setHeader.mock.calls).toEqual([ + ["Content-Type", "application/json"], + ["Cache-Control", "no-store"], + ]); + }); +}); diff --git a/themes/index.js b/themes/index.js index a5d3abae..60825d13 100644 --- a/themes/index.js +++ b/themes/index.js @@ -374,6 +374,13 @@ export const themes = { border_color: "170F0C", bg_color: "170F0C", }, + rose: { + title_color: "8d192b", + text_color: "862931", + icon_color: "B71F36", + border_color: "e9d8d4", + bg_color: "e9d8d4", + }, }; export default themes;