diff --git a/api/index.js b/api/index.js index c42bc04891234..6b14bf5839885 100644 --- a/api/index.js +++ b/api/index.js @@ -13,6 +13,8 @@ import { isLocaleAvailable } from "../src/translations.js"; export default async (req, res) => { const { username, + repos, + owners, hide, hide_title, hide_border, @@ -65,8 +67,35 @@ export default async (req, res) => { ); } + const safePattern = /^[-\w\/.,]+$/; + if ( + (username && !safePattern.test(username)) || + (repos && !safePattern.test(repos)) || + (owners && !safePattern.test(owners)) + ) { + return res.send( + renderError( + "Something went wrong", + "Username, repository or owner contains unsafe characters", + { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + ), + ); + } + try { const showStats = parseArray(show); + const organizations = parseArray(owners); + let repositories = parseArray(repos); + repositories = repositories.map((repo) => + repo.includes("/") ? repo : `${username}/${repo}`, + ); + const stats = await fetchStats( username, parseBoolean(include_all_commits), @@ -75,6 +104,13 @@ export default async (req, res) => { showStats.includes("prs_merged_percentage"), showStats.includes("discussions_started"), showStats.includes("discussions_answered"), + repositories, + organizations, + showStats.includes("prs_authored"), + showStats.includes("prs_commented"), + showStats.includes("prs_reviewed"), + showStats.includes("issues_authored"), + showStats.includes("issues_commented"), ); let cacheSeconds = clampValue( @@ -92,31 +128,37 @@ export default async (req, res) => { ); return res.send( - renderStatsCard(stats, { - hide: parseArray(hide), - show_icons: parseBoolean(show_icons), - hide_title: parseBoolean(hide_title), - hide_border: parseBoolean(hide_border), - card_width: parseInt(card_width, 10), - hide_rank: parseBoolean(hide_rank), - include_all_commits: parseBoolean(include_all_commits), - line_height, - title_color, - ring_color, - icon_color, - text_color, - text_bold: parseBoolean(text_bold), - bg_color, - theme, - custom_title, - border_radius, - border_color, - number_format, - locale: locale ? locale.toLowerCase() : null, - disable_animations: parseBoolean(disable_animations), - rank_icon, - show: showStats, - }), + renderStatsCard( + stats, + { + hide: parseArray(hide), + show_icons: parseBoolean(show_icons), + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + card_width: parseInt(card_width, 10), + hide_rank: parseBoolean(hide_rank), + include_all_commits: parseBoolean(include_all_commits), + line_height, + title_color, + ring_color, + icon_color, + text_color, + text_bold: parseBoolean(text_bold), + bg_color, + theme, + custom_title, + border_radius, + border_color, + number_format, + locale: locale ? locale.toLowerCase() : null, + disable_animations: parseBoolean(disable_animations), + rank_icon, + show: showStats, + }, + username, + repositories, + organizations, + ), ); } catch (err) { res.setHeader( diff --git a/api/pin.js b/api/pin.js index bede7d87f5972..2570eeb653838 100644 --- a/api/pin.js +++ b/api/pin.js @@ -3,6 +3,7 @@ import { blacklist } from "../src/common/blacklist.js"; import { clampValue, CONSTANTS, + parseArray, parseBoolean, renderError, } from "../src/common/utils.js"; @@ -18,8 +19,14 @@ export default async (req, res) => { icon_color, text_color, bg_color, + card_width, theme, show_owner, + show, + show_icons, + number_format, + text_bold, + line_height, cache_seconds, locale, border_radius, @@ -53,8 +60,37 @@ export default async (req, res) => { ); } + const safePattern = /^[-\w\/.,]+$/; + if ( + (username && !safePattern.test(username)) || + (repo && !safePattern.test(repo)) + ) { + return res.send( + renderError( + "Something went wrong", + "Username or repository contains unsafe characters", + { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + ), + ); + } + try { - const repoData = await fetchRepo(username, repo); + const showStats = parseArray(show); + const repoData = await fetchRepo( + username, + repo, + showStats.includes("prs_authored"), + showStats.includes("prs_commented"), + showStats.includes("prs_reviewed"), + showStats.includes("issues_authored"), + showStats.includes("issues_commented"), + ); let cacheSeconds = clampValue( parseInt(cache_seconds || CONSTANTS.PIN_CARD_CACHE_SECONDS, 10), @@ -80,7 +116,14 @@ export default async (req, res) => { theme, border_radius, border_color, + card_width_input: parseInt(card_width, 10), show_owner: parseBoolean(show_owner), + show: showStats, + show_icons: parseBoolean(show_icons), + number_format, + text_bold: parseBoolean(text_bold), + line_height, + username, locale: locale ? locale.toLowerCase() : null, description_lines_count, }), diff --git a/readme.md b/readme.md index 363b008b2f8c2..06b1f1b91512e 100644 --- a/readme.md +++ b/readme.md @@ -89,6 +89,7 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of - [Hiding individual stats](#hiding-individual-stats) - [Showing additional individual stats](#showing-additional-individual-stats) - [Showing icons](#showing-icons) + - [Filtering by repository and owner](#filtering-by-repository-and-owner) - [Themes](#themes) - [Customization](#customization) - [GitHub Extra Pins](#github-extra-pins) @@ -161,10 +162,10 @@ You can pass a query parameter `&hide=` to hide any specific stats with comma-se You can pass a query parameter `&show=` to show any specific additional stats with comma-separated values. -> Options: `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage` +> Options: `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` ```md -![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage) +![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) ``` ### Showing icons @@ -175,6 +176,12 @@ To enable icons, you can pass `&show_icons=true` in the query param, like so: ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true) ``` +### Filtering by repository and owner + +To compute your stats for only a specific repository, you can pass a query parameter `&repos=/`. You can also specify a comma-separated list of multiple repositories, e.g. `&repos=userA/repositoryA,organizationB/repositoryB`. And you can select all repositories owned by specific organizations or users by providing a comma-separated list of owners via the `owners` query parameter, e.g. `&owners=userA,organizationB,organizationC`. The `repos` and `owners` filters are supported by the following items: `commits` (when used with `&include_all_commits=true`), `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` and `issues_commented`. Note that most of these items are not displayed by default, but [you can enable them individually](#showing-additional-individual-stats). + +(Some of these mentioned items are similar to other items which are included by default, e.g. `issues_authored` is similar to `issues`. The difference is how these values are fetched - [via GraphQL or via REST API](https://github.com/anuraghazra/github-readme-stats/discussions/1770#number-of-commits-is-incorrect). The default items use GraphQL, but filtering by repository works better via REST API.) + ### Themes With inbuilt themes, you can customize the look of the card without doing any [manual customization](#customization). @@ -376,13 +383,15 @@ If we don't support your language, please consider contributing! You can find mo | `show_icons` | Shows icons near all stats. | boolean | `false` | | `include_all_commits` | Count total commits instead of just the current year commits. | boolean | `false` | | `line_height` | Sets the line height between text. | integer | `25` | -| `exclude_repo` | Excludes specified repositories. | string (comma-separated values) | `null` | +| `exclude_repo` | Excludes specified repositories. Affects only the count for "Total Stars Earned". | string (comma-separated values) | `null` | +| `repos` | Count only stats from the specified repositories. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | +| `owners` | Count only stats from the specified organizations or users. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | | `custom_title` | Sets a custom title for the card. | string | ` GitHub Stats` | | `text_bold` | Uses bold text. | boolean | `true` | | `disable_animations` | Disables all animations in the card. | boolean | `false` | | `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` | -| `number_format` | Switches between two available formats for displaying the card values `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | -| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`). | string (comma-separated values) | `null` | +| `number_format` | Switches between two available formats for displaying the card values: `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | +| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`. And the following, which support the `repos` and `owners` filters: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | > [!NOTE]\ > When hide\_rank=`true`, the minimum card width is 270 px + the title length and padding. @@ -393,6 +402,12 @@ If we don't support your language, please consider contributing! You can find mo | --- | --- | --- | --- | | `show_owner` | Shows the repo's owner name. | boolean | `false` | | `description_lines_count` | Manually set the number of lines for the description. Specified value will be clamped between 1 and 3. If this parameter is not specified, the number of lines will be automatically adjusted according to the actual length of the description. | number | `null` | +| `card_width` | Sets the card's width manually. | number | `400px (approx.)` | +| `show_icons` | Shows icons near all stats enabled via `show`. | boolean | `true` | +| `line_height` | Sets the line height between stats enabled via `show`. | integer | `22` | +| `text_bold` | Uses bold text for all stats enabled via `show`. | boolean | `false` | +| `number_format` | Switches between two available formats for displaying the numbers for all stats enabled via `show`: `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | +| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | #### Gist Card Exclusive Options @@ -458,10 +473,18 @@ Endpoint: `api/pin?username=anuraghazra&repo=github-readme-stats` ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats) -Use [show\_owner](#repo-card-exclusive-options) query option to include the repo's owner username +Use [show\_owner](#repo-card-exclusive-options) query option to include the repo's owner username: ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_owner=true) +Use [show](#repo-card-exclusive-options) query option to display the user's contributions to the repository: + +![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) + +You can also specify the `repo` parameter in the form `/` to pin a repository from any user or organization, not just your own. This allows you to showcase repositories you contributed to, regardless of ownership. + +![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=statykjs/statyk\&show_owner=true\&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) + # GitHub Gist Pins GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile. @@ -651,7 +674,15 @@ Change the `?username=` value to your [WakaTime](https://wakatime.com) username. * Showing additional stats -![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage) +![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show_icons=true\&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented) + +* Showing stats for a specific repository + +![Anurag's GitHub stats for anuraghazra/github-readme-stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&repos=anuraghazra/github-readme-stats\&hide=prs,issues,stars,commits,contribs\&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented\&hide_rank=true\&custom_title=Anurag%27s%20Stats%20for%20github-readme-stats\&card_width=370) + +* Showing stats for a specific organization + +![Anurag's GitHub stats for razorpay](https://github-readme-stats.vercel.app/api?username=anuraghazra\&owners=razorpay\&hide=prs,issues,stars,commits,contribs\&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented\&hide_rank=true\&custom_title=Anurag%27s%20Stats%20for%20razorpay\&card_width=370) * Showing icons diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index bbfda52d47778..08da78a9e7594 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -13,8 +13,10 @@ import { iconWithLabel, createLanguageNode, clampValue, + buildSearchFilter, } from "../common/utils.js"; import { repoCardLocales } from "../translations.js"; +import { createTextNode } from "./stats-card.js"; const ICON_SIZE = 16; const DESCRIPTION_LINE_WIDTH = 59; @@ -27,8 +29,8 @@ const DESCRIPTION_MAX_LINES = 3; * @param {string} textColor The color of the text. * @returns {string} Wrapped repo description SVG object. */ -const getBadgeSVG = (label, textColor) => ` - +const getBadgeSVG = (label, textColor, xOffset = 0) => ` + { isTemplate, starCount, forkCount, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, } = repo; const { hide_border = false, @@ -71,7 +78,14 @@ const renderRepoCard = (repo, options = {}) => { icon_color, text_color, bg_color, + card_width_input, show_owner = false, + show = [], + show_icons = true, + number_format = "short", + text_bold = false, + line_height = 22, + username, theme = "default_repocard", border_radius, border_color, @@ -79,6 +93,85 @@ const renderRepoCard = (repo, options = {}) => { description_lines_count, } = options; + const card_width = + card_width_input && !isNaN(card_width_input) + ? card_width_input + : show.length >= 2 + ? 430 + : 400; + + const i18n = new I18n({ + locale, + translations: repoCardLocales, + }); + + let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); + const STATS = {}; + if (show.includes("prs_authored")) { + STATS.prs_authored = { + icon: icons.prs, + label: i18n.t("repocard.prs-authored"), + value: totalPRsAuthored, + id: "prs_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_commented")) { + STATS.prs_commented = { + icon: icons.comments, + label: i18n.t("repocard.prs-commented"), + value: totalPRsCommented, + id: "prs_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_reviewed")) { + STATS.prs_reviewed = { + icon: icons.reviews, + label: i18n.t("repocard.prs-reviewed"), + value: totalPRsReviewed, + id: "prs_reviewed", + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("issues_authored")) { + STATS.issues_authored = { + icon: icons.issues, + label: i18n.t("repocard.issues-authored"), + value: totalIssuesAuthored, + id: "issues_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + }; + } + if (show.includes("issues_commented")) { + STATS.issues_commented = { + icon: icons.discussions_started, + label: i18n.t("repocard.issues-commented"), + value: totalIssuesCommented, + id: "issues_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + }; + } + + const statItems = Object.keys(STATS).map((key, index) => + // create the text nodes, and pass index so that we can calculate the line spacing + createTextNode({ + icon: STATS[key].icon, + label: STATS[key].label, + value: STATS[key].value, + id: STATS[key].id, + unitSymbol: STATS[key].unitSymbol, + index, + showIcons: show_icons, + shiftValuePos: 14.01, + bold: text_bold, + number_format, + link: STATS[key].link, + labelXOffset: 23, + }), + ); + + const extraLHeight = parseInt(String(line_height), 10); const lineHeight = 10; const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; @@ -90,7 +183,7 @@ const renderRepoCard = (repo, options = {}) => { const desc = parseEmojis(description || "No description provided"); const multiLineDescription = wrapTextMultiline( desc, - DESCRIPTION_LINE_WIDTH, + Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH), descriptionMaxLines, ); const descriptionLinesCount = description_lines_count @@ -101,14 +194,13 @@ const renderRepoCard = (repo, options = {}) => { .map((line) => `${encodeHTML(line)}`) .join(""); + const extraHeight = Object.keys(STATS).length + ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight + : 0; const height = (descriptionLinesCount > 1 ? 120 : 110) + - descriptionLinesCount * lineHeight; - - const i18n = new I18n({ - locale, - translations: repoCardLocales, - }); + descriptionLinesCount * lineHeight + + extraHeight; // returns theme based colors with proper overrides and defaults const colors = getCardColors({ @@ -149,10 +241,30 @@ const renderRepoCard = (repo, options = {}) => { gap: 25, }).join(""); + let extraRows = []; + for (let i = 0; i < statItems.length; i += 2) { + extraRows.push( + flexLayout({ + items: statItems.slice(i, i + 2), + gap: 210, + direction: "row", + }).join(""), + ); + } + const extraItems = ` + + ${flexLayout({ + items: extraRows, + gap: extraLHeight, + direction: "column", + }).join("")} + + `; + const card = new Card({ defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, titlePrefixIcon: icons.contribs, - width: 400, + width: card_width, height, border_radius, colors, @@ -164,19 +276,38 @@ const renderRepoCard = (repo, options = {}) => { card.setCSS(` .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } - .icon { fill: ${colors.iconColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } + + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .not_bold { font-weight: 400 } + .bold { font-weight: 700 } + .icon { + fill: ${colors.iconColor}; + display: block; + } `); return card.render(` ${ isTemplate ? // @ts-ignore - getBadgeSVG(i18n.t("repocard.template"), colors.textColor) + getBadgeSVG( + i18n.t("repocard.template"), + colors.textColor, + card_width - 400, + ) : isArchived ? // @ts-ignore - getBadgeSVG(i18n.t("repocard.archived"), colors.textColor) + getBadgeSVG( + i18n.t("repocard.archived"), + colors.textColor, + card_width - 400, + ) : "" } @@ -184,9 +315,10 @@ const renderRepoCard = (repo, options = {}) => { ${descriptionSvg} - + ${starAndForkCount} + ${extraItems} `); }; diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 5b7f0d268f9bd..e0b4f6834d299 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -9,6 +9,7 @@ import { getCardColors, kFormatter, measureText, + buildSearchFilter, } from "../common/utils.js"; import { statCardLocales } from "../translations.js"; @@ -33,6 +34,8 @@ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290; * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right. * @param {boolean} createTextNodeParams.bold Whether to bold the label. * @param {string} createTextNodeParams.number_format The format of numbers on card. + * @param {string} createTextNodeParams.link Url to link to. + * @param {number} createTextNodeParams.labelXOffset horizontal offset for label. * @returns {string} The stats card text item SVG object. */ const createTextNode = ({ @@ -46,12 +49,14 @@ const createTextNode = ({ shiftValuePos, bold, number_format, + link, + labelXOffset = 25, }) => { const kValue = number_format.toLowerCase() === "long" ? value : kFormatter(value); const staggerDelay = (index + 3) * 150; - const labelOffset = showIcons ? `x="25"` : ""; + const labelOffset = showIcons ? `x="${labelXOffset}"` : ""; const iconSvg = showIcons ? ` @@ -59,20 +64,26 @@ const createTextNode = ({ ` : ""; - return ` - + return ( + ` + ` + + (link ? `` : "") + + ` ${iconSvg} ${label}: ${kValue}${unitSymbol ? ` ${unitSymbol}` : ""} + >${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}` + + (link ? "" : "") + + ` - `; + ` + ); }; /** @@ -199,7 +210,13 @@ const getStyles = ({ * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ -const renderStatsCard = (stats, options = {}) => { +const renderStatsCard = ( + stats, + options = {}, + username, + repos = [], + owners = [], +) => { const { name, totalStars, @@ -212,6 +229,11 @@ const renderStatsCard = (stats, options = {}) => { totalDiscussionsStarted, totalDiscussionsAnswered, contributedTo, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, rank, } = stats; const { @@ -338,6 +360,53 @@ const renderStatsCard = (stats, options = {}) => { }; } + let repoFilter = encodeURIComponent(buildSearchFilter(repos, owners)); + if (show.includes("prs_authored")) { + STATS.prs_authored = { + icon: icons.prs, + label: i18n.t("statcard.prs-authored"), + value: totalPRsAuthored, + id: "prs_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_commented")) { + STATS.prs_commented = { + icon: icons.comments, + label: i18n.t("statcard.prs-commented"), + value: totalPRsCommented, + id: "prs_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_reviewed")) { + STATS.prs_reviewed = { + icon: icons.reviews, + label: i18n.t("statcard.prs-reviewed"), + value: totalPRsReviewed, + id: "prs_reviewed", + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("issues_authored")) { + STATS.issues_authored = { + icon: icons.issues, + label: i18n.t("statcard.issues-authored"), + value: totalIssuesAuthored, + id: "issues_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + }; + } + if (show.includes("issues_commented")) { + STATS.issues_commented = { + icon: icons.discussions_started, + label: i18n.t("statcard.issues-commented"), + value: totalIssuesCommented, + id: "issues_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + }; + } + STATS.contribs = { icon: icons.contribs, label: i18n.t("statcard.contribs"), @@ -363,6 +432,12 @@ const renderStatsCard = (stats, options = {}) => { ]; const isLongLocale = locale ? longLocales.includes(locale) : false; + // check if all used labels are short + const longLabels = + Object.keys(STATS) + .filter((key) => !hide.includes(key)) + .filter((key) => STATS[key].label.length > 18).length > 0; + // filter out hidden stats defined by user & create the text nodes const statItems = Object.keys(STATS) .filter((key) => !hide.includes(key)) @@ -376,9 +451,10 @@ const renderStatsCard = (stats, options = {}) => { unitSymbol: STATS[key].unitSymbol, index, showIcons: show_icons, - shiftValuePos: 79.01 + (isLongLocale ? 50 : 0), + shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0), bold: text_bold, number_format, + link: STATS[key].link, }), ); @@ -441,12 +517,9 @@ const renderStatsCard = (stats, options = {}) => { : RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth; let width = card_width ? isNaN(card_width) - ? defaultCardWidth + ? Math.max(defaultCardWidth, minCardWidth) : card_width - : defaultCardWidth; - if (width < minCardWidth) { - width = minCardWidth; - } + : Math.max(defaultCardWidth, minCardWidth); const card = new Card({ customTitle: custom_title, @@ -541,5 +614,5 @@ const renderStatsCard = (stats, options = {}) => { `); }; -export { renderStatsCard }; +export { renderStatsCard, createTextNode }; export default renderStatsCard; diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index 9a21be4a0160a..4549d3c8c5143 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -33,6 +33,13 @@ export type StatCardOptions = CommonOptions & { export type RepoCardOptions = CommonOptions & { show_owner: boolean; description_lines_count: number; + card_width_input; + show: string[]; + show_icons: boolean; + number_format: string; + text_bold: boolean; + line_height: number | string; + username; }; export type TopLangOptions = CommonOptions & { diff --git a/src/common/icons.js b/src/common/icons.js index 771704a335d12..b08f3fbb0ce73 100644 --- a/src/common/icons.js +++ b/src/common/icons.js @@ -11,6 +11,7 @@ const icons = { reviews: ``, discussions_started: ``, discussions_answered: ``, + comments: ``, gist: ``, }; diff --git a/src/common/retryer.js b/src/common/retryer.js index 3f294d3751327..bbef173f37535 100644 --- a/src/common/retryer.js +++ b/src/common/retryer.js @@ -45,7 +45,7 @@ const retryer = async (fetcher, variables, retries = 0) => { // if rate limit is hit increase the RETRIES and recursively call the retryer // with username, and current RETRIES if (isRateExceeded) { - logger.log(`PAT_${retries + 1} Failed`); + logger.log(`PAT_${retries + 1} Failed due to rate limiting`); retries++; // directly return from the function return retryer(fetcher, variables, retries); @@ -62,7 +62,7 @@ const retryer = async (fetcher, variables, retries = 0) => { err.response.data.message === "Sorry. Your account was suspended."; if (isBadCredential || isAccountSuspended) { - logger.log(`PAT_${retries + 1} Failed`); + logger.log(`PAT_${retries + 1} Failed due to bad credentials`); retries++; // directly return from the function return retryer(fetcher, variables, retries); diff --git a/src/common/utils.js b/src/common/utils.js index b780657c1c244..81ebbfcc5cc73 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -217,6 +217,18 @@ const fallbackColor = (color, fallbackColor) => { ); }; +const buildSearchFilter = (repos = [], owners = []) => { + let repoFilter = + Array.isArray(repos) && repos.length > 0 + ? repos.map((r) => `repo:${r} `).join("") + : ""; + let orgFilter = + Array.isArray(owners) && owners.length > 0 + ? owners.map((o) => `owner:${o} `).join("") + : ""; + return repoFilter + orgFilter; +}; + /** * @typedef {import('axios').AxiosRequestConfig['data']} AxiosRequestConfigData Axios request data. * @typedef {import('axios').AxiosRequestConfig['headers']} AxiosRequestConfigHeaders Axios request headers. @@ -612,6 +624,7 @@ export { clampValue, isValidGradient, fallbackColor, + buildSearchFilter, request, flexLayout, getCardColors, diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 6438f8895cfb6..f2764f175928d 100644 --- a/src/fetchers/repo-fetcher.js +++ b/src/fetchers/repo-fetcher.js @@ -1,6 +1,7 @@ // @ts-check import { retryer } from "../common/retryer.js"; import { MissingParamError, request } from "../common/utils.js"; +import { fetchRepoUserStats } from "./stats-fetcher.js"; /** * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. @@ -69,7 +70,28 @@ const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; * @param {string} reponame GitHub repository name. * @returns {Promise} Repository data. */ -const fetchRepo = async (username, reponame) => { +const fetchRepo = async ( + username, + reponame, + include_prs_authored = false, + include_prs_commented = false, + include_prs_reviewed = false, + include_issues_authored = false, + include_issues_commented = false, +) => { + let owner = username; + if (reponame && reponame.includes("/")) { + const [parsed_owner, parsed_repo] = reponame.split("/"); + owner = parsed_owner; + reponame = parsed_repo; + } + + if (owner && !username) { + username = owner; + } + if (username && !owner) { + owner = username; + } if (!username && !reponame) { throw new MissingParamError(["username", "repo"], urlExample); } @@ -80,7 +102,7 @@ const fetchRepo = async (username, reponame) => { throw new MissingParamError(["repo"], urlExample); } - let res = await retryer(fetcher, { login: username, repo: reponame }); + let res = await retryer(fetcher, { login: owner, repo: reponame }); const data = res.data.data; @@ -95,7 +117,18 @@ const fetchRepo = async (username, reponame) => { if (!data.user.repository || data.user.repository.isPrivate) { throw new Error("User Repository Not found"); } + let repoUserStats = await fetchRepoUserStats( + username, + [owner + "/" + reponame], + [], + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, + ); return { + ...repoUserStats, ...data.user.repository, starCount: data.user.repository.stargazers.totalCount, }; @@ -108,7 +141,18 @@ const fetchRepo = async (username, reponame) => { ) { throw new Error("Organization Repository Not found"); } + let repoUserStats = await fetchRepoUserStats( + username, + [owner + "/" + reponame], + [], + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, + ); return { + ...repoUserStats, ...data.organization.repository, starCount: data.organization.repository.stargazers.totalCount, }; diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 115cd50a51564..dc0003e4ac10f 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -5,6 +5,7 @@ import githubUsernameRegex from "github-username-regex"; import { calculateRank } from "../calculateRank.js"; import { retryer } from "../common/retryer.js"; import { + buildSearchFilter, CustomError, logger, MissingParamError, @@ -167,17 +168,25 @@ const statsFetcher = async ({ * @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 totalCommitsFetcher = async (username) => { +const totalItemsFetcher = async (username, repos, owners, 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 fetchTotalCommits = (variables, token) => { + const fetchTotalItems = (variables, token) => { return axios({ method: "get", - url: `https://api.github.com/search/commits?q=author:${variables.login}`, + url: + `https://api.github.com/search/` + + type + + `?per_page=1&q=` + + buildSearchFilter(variables.repos, variables.owners).replaceAll( + " ", + "+", + ) + + filter, headers: { "Content-Type": "application/json", Accept: "application/vnd.github.cloak-preview", @@ -188,22 +197,81 @@ const totalCommitsFetcher = async (username) => { let res; try { - res = await retryer(fetchTotalCommits, { login: username }); + res = await retryer(fetchTotalItems, { login: username, repos, owners }); } catch (err) { logger.log(err); throw new Error(err); } const totalCount = res.data.total_count; - if (!totalCount || isNaN(totalCount)) { + if (isNaN(totalCount)) { throw new CustomError( - "Could not fetch total commits.", + "Could not fetch data from GitHub REST API.", CustomError.GITHUB_REST_API_ERROR, ); } return totalCount; }; +const fetchRepoUserStats = async ( + username, + repos, + owners, + 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, + repos, + owners, + "issues", + `author:${username}+type:pr`, + ); + } + if (include_prs_commented) { + stats.totalPRsCommented = await totalItemsFetcher( + username, + repos, + owners, + "issues", + `commenter:${username}+-author:${username}+type:pr`, + ); + } + if (include_prs_reviewed) { + stats.totalPRsReviewed = await totalItemsFetcher( + username, + repos, + owners, + "issues", + `reviewed-by:${username}+-author:${username}+type:pr`, + ); + } + if (include_issues_authored) { + stats.totalIssuesAuthored = await totalItemsFetcher( + username, + repos, + owners, + "issues", + `author:${username}+type:issue`, + ); + } + if (include_issues_commented) { + stats.totalIssuesCommented = await totalItemsFetcher( + username, + repos, + owners, + "issues", + `commenter:${username}+-author:${username}+type:issue`, + ); + } + return stats; +}; + /** * @typedef {import("./types").StatsData} StatsData Stats data. */ @@ -226,6 +294,13 @@ const fetchStats = async ( include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, + repos = [], + owners = [], + include_prs_authored = false, + include_prs_commented = false, + include_prs_reviewed = false, + include_issues_authored = false, + include_issues_commented = false, ) => { if (!username) { throw new MissingParamError(["username"]); @@ -243,6 +318,11 @@ const fetchStats = async ( totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, contributedTo: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank: { level: "C", percentile: 100 }, }; @@ -280,10 +360,27 @@ const fetchStats = async ( // if include_all_commits, fetch all commits using the REST API. if (include_all_commits) { - stats.totalCommits = await totalCommitsFetcher(username); + stats.totalCommits = await totalItemsFetcher( + username, + repos, + owners, + "commits", + `author:${username}`, + ); } else { stats.totalCommits = user.contributionsCollection.totalCommitContributions; } + let repoUserStats = await fetchRepoUserStats( + username, + repos, + owners, + 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) { @@ -328,5 +425,5 @@ const fetchStats = async ( return stats; }; -export { fetchStats }; +export { fetchStats, fetchRepoUserStats }; export default fetchStats; diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts index affb407b816b0..1588c921290b6 100644 --- a/src/fetchers/types.d.ts +++ b/src/fetchers/types.d.ts @@ -22,6 +22,11 @@ export type RepositoryData = { }; forkCount: number; starCount: number; + totalPRsAuthored: number; + totalPRsCommented: number; + totalPRsReviewed: number; + totalIssuesAuthored: number; + totalIssuesCommented: number; }; export type StatsData = { @@ -36,6 +41,11 @@ export type StatsData = { totalDiscussionsStarted: number; totalDiscussionsAnswered: number; contributedTo: number; + totalPRsAuthored: number; + totalPRsCommented: number; + totalPRsReviewed: number; + totalIssuesAuthored: number; + totalIssuesCommented: number; rank: { level: string; percentile: number }; }; diff --git a/src/translations.js b/src/translations.js index aa8744d7e1391..fe03b60d6ae15 100644 --- a/src/translations.js +++ b/src/translations.js @@ -325,6 +325,21 @@ const statCardLocales = ({ name, apostrophe }) => { vi: "Tổng Số Thảo Luận Đã Trả Lời", se: "Totalt antal diskussioner besvarade", }, + "statcard.prs-authored": { + en: "PRs Created", + }, + "statcard.prs-commented": { + en: "PRs Commented", + }, + "statcard.prs-reviewed": { + en: "PRs Reviewed", + }, + "statcard.issues-authored": { + en: "Issues Created", + }, + "statcard.issues-commented": { + en: "Issues Commented", + }, "statcard.prs-merged": { ar: "مجموع الطلبات المدمجة", cn: "合并的 PR 总数", @@ -451,6 +466,21 @@ const repoCardLocales = { vi: "Đã Lưu Trữ", se: "Arkiverade", }, + "repocard.prs-authored": { + en: "my created PRs", + }, + "repocard.prs-commented": { + en: "my commented PRs", + }, + "repocard.prs-reviewed": { + en: "my reviewed PRs", + }, + "repocard.issues-authored": { + en: "my created issues", + }, + "repocard.issues-commented": { + en: "my commented issues", + }, }; const langCardLocales = { diff --git a/tests/api.test.js b/tests/api.test.js index c155220ce5372..48dc08462063d 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -242,7 +242,9 @@ describe("Test /api/", () => { it("should render error card when include_all_commits true and upstream API fails", async () => { mock - .onGet("https://api.github.com/search/commits?q=author:anuraghazra") + .onGet( + "https://api.github.com/search/commits?per_page=1&q=author:anuraghazra", + ) .reply(200, { error: "Some test error message" }); const { req, res } = faker( @@ -254,7 +256,10 @@ describe("Test /api/", () => { expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toBeCalledWith( - renderError("Could not fetch total commits.", "Please try again later"), + renderError( + "Could not fetch data from GitHub REST API.", + "Please try again later", + ), ); // Received SVG output should not contain string "https://tiny.one/readme-stats" expect(res.send.mock.calls[0][0]).not.toContain( diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js index ca8d7bc37062e..4908d11e16811 100644 --- a/tests/fetchStats.test.js +++ b/tests/fetchStats.test.js @@ -128,6 +128,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -164,6 +169,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -179,7 +189,9 @@ describe("Test fetchStats", () => { it("should fetch total commits", async () => { mock - .onGet("https://api.github.com/search/commits?q=author:anuraghazra") + .onGet( + "https://api.github.com/search/commits?per_page=1&q=author:anuraghazra", + ) .reply(200, { total_count: 1000 }); let stats = await fetchStats("anuraghazra", true); @@ -206,6 +218,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -218,17 +235,21 @@ describe("Test fetchStats", () => { it("should throw specific error when include_all_commits true and API returns error", async () => { mock - .onGet("https://api.github.com/search/commits?q=author:anuraghazra") + .onGet( + "https://api.github.com/search/commits?per_page=1&q=author:anuraghazra", + ) .reply(200, { error: "Some test error message" }); expect(fetchStats("anuraghazra", true)).rejects.toThrow( - new Error("Could not fetch total commits."), + new Error("Could not fetch data from GitHub REST API."), ); }); it("should exclude stars of the `test-repo-1` repository", async () => { mock - .onGet("https://api.github.com/search/commits?q=author:anuraghazra") + .onGet( + "https://api.github.com/search/commits?per_page=1&q=author:anuraghazra", + ) .reply(200, { total_count: 1000 }); let stats = await fetchStats("anuraghazra", true, ["test-repo-1"]); @@ -255,6 +276,11 @@ describe("Test fetchStats", () => { totalStars: 200, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -286,6 +312,11 @@ describe("Test fetchStats", () => { totalStars: 400, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -317,6 +348,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -348,6 +384,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -377,6 +418,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); @@ -406,6 +452,11 @@ describe("Test fetchStats", () => { totalStars: 300, totalDiscussionsStarted: 10, totalDiscussionsAnswered: 40, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, rank, }); }); diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js index 973ee0a5a5db6..1b8229425c5bb 100644 --- a/tests/renderStatsCard.test.js +++ b/tests/renderStatsCard.test.js @@ -139,17 +139,14 @@ describe("Test renderStatsCard", () => { 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", "420"); + expect(document.querySelector("svg")).toHaveAttribute("width", "1"); // Test default minimum card width without rank circle. document.body.innerHTML = renderStatsCard(stats, { card_width: 1, hide_rank: true, }); - expect(document.querySelector("svg")).toHaveAttribute( - "width", - "305.81250000000006", - ); + expect(document.querySelector("svg")).toHaveAttribute("width", "1"); // Test minimum card width with rank and icons. document.body.innerHTML = renderStatsCard(stats, { @@ -157,10 +154,7 @@ describe("Test renderStatsCard", () => { hide_rank: true, show_icons: true, }); - expect(document.querySelector("svg")).toHaveAttribute( - "width", - "322.81250000000006", - ); + expect(document.querySelector("svg")).toHaveAttribute("width", "1"); // Test minimum card width with icons but without rank. document.body.innerHTML = renderStatsCard(stats, { @@ -168,7 +162,7 @@ describe("Test renderStatsCard", () => { hide_rank: false, show_icons: true, }); - expect(document.querySelector("svg")).toHaveAttribute("width", "437"); + expect(document.querySelector("svg")).toHaveAttribute("width", "1"); // Test minimum card width without icons or rank. document.body.innerHTML = renderStatsCard(stats, { @@ -176,7 +170,7 @@ describe("Test renderStatsCard", () => { hide_rank: false, show_icons: false, }); - expect(document.querySelector("svg")).toHaveAttribute("width", "420"); + expect(document.querySelector("svg")).toHaveAttribute("width", "1"); }); it("should render default colors properly", () => {