From f91b400c439624c97c607115512228dc1c275ffb Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 18 May 2025 21:53:13 +0200 Subject: [PATCH 01/29] add repo filter --- api/index.js | 62 +++++++++++++++---------- readme.md | 16 ++++++- src/cards/stats-card.js | 86 ++++++++++++++++++++++++++++++----- src/common/icons.js | 1 + src/common/retryer.js | 4 +- src/fetchers/stats-fetcher.js | 73 ++++++++++++++++++++++++++--- src/fetchers/types.d.ts | 5 ++ src/translations.js | 15 ++++++ tests/api.test.js | 9 +++- tests/fetchStats.test.js | 59 ++++++++++++++++++++++-- tests/renderStatsCard.test.js | 16 ++----- 11 files changed, 284 insertions(+), 62 deletions(-) diff --git a/api/index.js b/api/index.js index c42bc04891234..123a03d6c6f36 100644 --- a/api/index.js +++ b/api/index.js @@ -13,6 +13,7 @@ import { isLocaleAvailable } from "../src/translations.js"; export default async (req, res) => { const { username, + repo, hide, hide_title, hide_border, @@ -75,6 +76,12 @@ export default async (req, res) => { showStats.includes("prs_merged_percentage"), showStats.includes("discussions_started"), showStats.includes("discussions_answered"), + repo, + 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 +99,36 @@ 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, + repo, + ), ); } catch (err) { res.setHeader( diff --git a/readme.md b/readme.md index 363b008b2f8c2..7363686f928bf 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](#filtering-by-repository) - [Themes](#themes) - [Customization](#customization) - [GitHub Extra Pins](#github-extra-pins) @@ -175,6 +176,14 @@ 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 + +To exclude specific repositories, you can use the [`&exclude_repo` parameter](#stats-card-exclusive-options). + +To compute your stats for only one specific repository, you can pass a query parameter `&repo=/`. This filter is supported by the following items: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` and `issues_commented`. Note that 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 retrieved - [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). @@ -377,12 +386,13 @@ If we don't support your language, please consider contributing! You can find mo | `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` | +| `repo` | Count only stats from the specified repository. Affects only the items `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` and `issues_commented`. | string | `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` | +| `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/Or the following, which support the `repo` filter: `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. @@ -653,6 +663,10 @@ Change the `?username=` value to your [WakaTime](https://wakatime.com) username. ![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) +* Showing stats for a specific repository + +![Anurag's GitHub stats for anuraghazra/github-readme-stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&repo=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 icons ![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&hide=issues\&show_icons=true) diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 5b7f0d268f9bd..a9b4ca7b6091b 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -33,6 +33,7 @@ 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. * @returns {string} The stats card text item SVG object. */ const createTextNode = ({ @@ -46,6 +47,7 @@ const createTextNode = ({ shiftValuePos, bold, number_format, + link, }) => { const kValue = number_format.toLowerCase() === "long" ? value : kFormatter(value); @@ -59,8 +61,11 @@ const createTextNode = ({ ` : ""; - return ` - + return ( + ` + ` + + (link ? `` : "") + + ` ${iconSvg} ${kValue}${unitSymbol ? ` ${unitSymbol}` : ""} + >${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}` + + (link ? "" : "") + + ` - `; + ` + ); }; /** @@ -199,7 +207,7 @@ const getStyles = ({ * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ -const renderStatsCard = (stats, options = {}) => { +const renderStatsCard = (stats, options = {}, username, repo) => { const { name, totalStars, @@ -212,6 +220,11 @@ const renderStatsCard = (stats, options = {}) => { totalDiscussionsStarted, totalDiscussionsAnswered, contributedTo, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, rank, } = stats; const { @@ -338,6 +351,53 @@ const renderStatsCard = (stats, options = {}) => { }; } + let repoFilter = repo ? "repo%3A" + encodeURIComponent(repo) + "+" : ""; + 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 +423,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 +442,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 +508,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, 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/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 115cd50a51564..62cae1ce93171 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -167,17 +167,22 @@ 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, repo, 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=` + + filter + + (variables.repo ? `+repo:${variables.repo}` : ``), headers: { "Content-Type": "application/json", Accept: "application/vnd.github.cloak-preview", @@ -188,7 +193,7 @@ const totalCommitsFetcher = async (username) => { let res; try { - res = await retryer(fetchTotalCommits, { login: username }); + res = await retryer(fetchTotalItems, { login: username, repo }); } catch (err) { logger.log(err); throw new Error(err); @@ -197,7 +202,7 @@ const totalCommitsFetcher = async (username) => { const totalCount = res.data.total_count; if (!totalCount || isNaN(totalCount)) { throw new CustomError( - "Could not fetch total commits.", + "Could not fetch data from GitHub REST API.", CustomError.GITHUB_REST_API_ERROR, ); } @@ -226,6 +231,12 @@ const fetchStats = async ( include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, + repo = null, + 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 +254,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 +296,55 @@ 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, + repo, + "commits", + `author:${username}`, + ); } else { stats.totalCommits = user.contributionsCollection.totalCommitContributions; } + if (include_prs_authored) { + stats.totalPRsAuthored = await totalItemsFetcher( + username, + repo, + "issues", + `author:${username}+type:pr`, + ); + } + if (include_prs_commented) { + stats.totalPRsCommented = await totalItemsFetcher( + username, + repo, + "issues", + `commenter:${username}+-author:${username}+type:pr`, + ); + } + if (include_prs_reviewed) { + stats.totalPRsReviewed = await totalItemsFetcher( + username, + repo, + "issues", + `reviewed-by:${username}+-author:${username}+type:pr`, + ); + } + if (include_issues_authored) { + stats.totalIssuesAuthored = await totalItemsFetcher( + username, + repo, + "issues", + `author:${username}+type:issue`, + ); + } + if (include_issues_commented) { + stats.totalIssuesCommented = await totalItemsFetcher( + username, + repo, + "issues", + `commenter:${username}+-author:${username}+type:issue`, + ); + } stats.totalPRs = user.pullRequests.totalCount; if (include_merged_pull_requests) { diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts index affb407b816b0..942e3af329977 100644 --- a/src/fetchers/types.d.ts +++ b/src/fetchers/types.d.ts @@ -36,6 +36,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..4baab4037bcdc 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 总数", 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", () => { From 0b2c71aebe44f5a09eb67477ad875fad4f7763cd Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 20 May 2025 13:17:52 +0200 Subject: [PATCH 02/29] support multiple orgs and repos --- api/index.js | 11 ++++++++--- src/cards/stats-card.js | 6 +++--- src/common/utils.js | 11 +++++++++++ src/fetchers/stats-fetcher.js | 30 +++++++++++++++++++----------- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/api/index.js b/api/index.js index 123a03d6c6f36..51091feaaa8e7 100644 --- a/api/index.js +++ b/api/index.js @@ -13,7 +13,8 @@ import { isLocaleAvailable } from "../src/translations.js"; export default async (req, res) => { const { username, - repo, + repos, + orgs, hide, hide_title, hide_border, @@ -68,6 +69,8 @@ export default async (req, res) => { try { const showStats = parseArray(show); + const repositories=parseArray(repos); + const organizations=parseArray(orgs); const stats = await fetchStats( username, parseBoolean(include_all_commits), @@ -76,7 +79,8 @@ export default async (req, res) => { showStats.includes("prs_merged_percentage"), showStats.includes("discussions_started"), showStats.includes("discussions_answered"), - repo, + repositories, + organizations, showStats.includes("prs_authored"), showStats.includes("prs_commented"), showStats.includes("prs_reviewed"), @@ -127,7 +131,8 @@ export default async (req, res) => { show: showStats, }, username, - repo, + repositories, + organizations, ), ); } catch (err) { diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index a9b4ca7b6091b..420157a0dc5ff 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -8,7 +8,7 @@ import { flexLayout, getCardColors, kFormatter, - measureText, + measureText, buildSearchFilter, } from "../common/utils.js"; import { statCardLocales } from "../translations.js"; @@ -207,7 +207,7 @@ const getStyles = ({ * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ -const renderStatsCard = (stats, options = {}, username, repo) => { +const renderStatsCard = (stats, options = {}, username, repos=[], orgs=[]) => { const { name, totalStars, @@ -351,7 +351,7 @@ const renderStatsCard = (stats, options = {}, username, repo) => { }; } - let repoFilter = repo ? "repo%3A" + encodeURIComponent(repo) + "+" : ""; + let repoFilter = buildSearchFilter(repos, orgs); if (show.includes("prs_authored")) { STATS.prs_authored = { icon: icons.prs, diff --git a/src/common/utils.js b/src/common/utils.js index b780657c1c244..6a91ab00fd530 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -217,6 +217,16 @@ const fallbackColor = (color, fallbackColor) => { ); }; +const buildSearchFilter = (repos=[], orgs=[])=>{ + let repoFilter = Array.isArray(repos) && repos.length > 0 + ? repos.map((r) => `repo%3A${encodeURIComponent(r)}`).join("+") + "+" + : ""; + let orgFilter = Array.isArray(orgs) && orgs.length > 0 + ? orgs.map((o) => `org%3A${encodeURIComponent(org)}`).join("+") + "+" + : ""; + return repoFilter + orgFilter; +} + /** * @typedef {import('axios').AxiosRequestConfig['data']} AxiosRequestConfigData Axios request data. * @typedef {import('axios').AxiosRequestConfig['headers']} AxiosRequestConfigHeaders Axios request headers. @@ -612,6 +622,7 @@ export { clampValue, isValidGradient, fallbackColor, + buildSearchFilter, request, flexLayout, getCardColors, diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 62cae1ce93171..a97698e8dc28a 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,7 +168,7 @@ 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 totalItemsFetcher = async (username, repo, type, filter) => { +const totalItemsFetcher = async (username, repos, orgs, type, filter) => { if (!githubUsernameRegex.test(username)) { logger.log("Invalid username provided."); throw new Error("Invalid username provided."); @@ -181,8 +182,8 @@ const totalItemsFetcher = async (username, repo, type, filter) => { `https://api.github.com/search/` + type + `?per_page=1&q=` + - filter + - (variables.repo ? `+repo:${variables.repo}` : ``), + buildSearchFilter(variables.repos, variables.orgs)+ + filter, headers: { "Content-Type": "application/json", Accept: "application/vnd.github.cloak-preview", @@ -193,7 +194,7 @@ const totalItemsFetcher = async (username, repo, type, filter) => { let res; try { - res = await retryer(fetchTotalItems, { login: username, repo }); + res = await retryer(fetchTotalItems, { login: username, repos, orgs }); } catch (err) { logger.log(err); throw new Error(err); @@ -231,7 +232,8 @@ const fetchStats = async ( include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, - repo = null, + repos=[], + orgs=[], include_prs_authored = false, include_prs_commented = false, include_prs_reviewed = false, @@ -298,7 +300,8 @@ const fetchStats = async ( if (include_all_commits) { stats.totalCommits = await totalItemsFetcher( username, - repo, + repos, + orgs, "commits", `author:${username}`, ); @@ -308,7 +311,8 @@ const fetchStats = async ( if (include_prs_authored) { stats.totalPRsAuthored = await totalItemsFetcher( username, - repo, + repos, + orgs, "issues", `author:${username}+type:pr`, ); @@ -316,7 +320,8 @@ const fetchStats = async ( if (include_prs_commented) { stats.totalPRsCommented = await totalItemsFetcher( username, - repo, + repos, + orgs, "issues", `commenter:${username}+-author:${username}+type:pr`, ); @@ -324,7 +329,8 @@ const fetchStats = async ( if (include_prs_reviewed) { stats.totalPRsReviewed = await totalItemsFetcher( username, - repo, + repos, + orgs, "issues", `reviewed-by:${username}+-author:${username}+type:pr`, ); @@ -332,7 +338,8 @@ const fetchStats = async ( if (include_issues_authored) { stats.totalIssuesAuthored = await totalItemsFetcher( username, - repo, + repos, + orgs, "issues", `author:${username}+type:issue`, ); @@ -340,7 +347,8 @@ const fetchStats = async ( if (include_issues_commented) { stats.totalIssuesCommented = await totalItemsFetcher( username, - repo, + repos, + orgs, "issues", `commenter:${username}+-author:${username}+type:issue`, ); From 5ffac3b158f0f165f7fb5de230cfa71bd3874df1 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 20 May 2025 15:38:24 +0000 Subject: [PATCH 03/29] fix code and style --- src/common/utils.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/common/utils.js b/src/common/utils.js index 6a91ab00fd530..77ca6071cd0c0 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -217,15 +217,17 @@ const fallbackColor = (color, fallbackColor) => { ); }; -const buildSearchFilter = (repos=[], orgs=[])=>{ - let repoFilter = Array.isArray(repos) && repos.length > 0 - ? repos.map((r) => `repo%3A${encodeURIComponent(r)}`).join("+") + "+" - : ""; - let orgFilter = Array.isArray(orgs) && orgs.length > 0 - ? orgs.map((o) => `org%3A${encodeURIComponent(org)}`).join("+") + "+" - : ""; +const buildSearchFilter = (repos = [], orgs = []) => { + let repoFilter = + Array.isArray(repos) && repos.length > 0 + ? repos.map((r) => `repo%3A${encodeURIComponent(r)}`).join("+") + "+" + : ""; + let orgFilter = + Array.isArray(orgs) && orgs.length > 0 + ? orgs.map((o) => `org%3A${encodeURIComponent(o)}`).join("+") + "+" + : ""; return repoFilter + orgFilter; -} +}; /** * @typedef {import('axios').AxiosRequestConfig['data']} AxiosRequestConfigData Axios request data. From bfe9ed2799fa73f6e40d7a8d664b8718f7640dc6 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 20:06:11 +0200 Subject: [PATCH 04/29] add items and options to repo card, validate input, orgs->owners --- api/index.js | 21 +++++- api/pin.js | 40 ++++++++++- src/cards/repo-card.js | 95 ++++++++++++++++++++++++- src/cards/stats-card.js | 6 +- src/common/utils.js | 6 +- src/fetchers/repo-fetcher.js | 30 +++++++- src/fetchers/stats-fetcher.js | 127 ++++++++++++++++++++-------------- src/translations.js | 15 ++++ 8 files changed, 275 insertions(+), 65 deletions(-) diff --git a/api/index.js b/api/index.js index 51091feaaa8e7..a7da4bed4a4d5 100644 --- a/api/index.js +++ b/api/index.js @@ -14,7 +14,7 @@ export default async (req, res) => { const { username, repos, - orgs, + owners, hide, hide_title, hide_border, @@ -67,10 +67,27 @@ 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 repositories=parseArray(repos); - const organizations=parseArray(orgs); + const organizations=parseArray(owners); const stats = await fetchStats( username, parseBoolean(include_all_commits), diff --git a/api/pin.js b/api/pin.js index bede7d87f5972..5ab745a2b9880 100644 --- a/api/pin.js +++ b/api/pin.js @@ -2,7 +2,7 @@ import { renderRepoCard } from "../src/cards/repo-card.js"; import { blacklist } from "../src/common/blacklist.js"; import { clampValue, - CONSTANTS, + CONSTANTS, parseArray, parseBoolean, renderError, } from "../src/common/utils.js"; @@ -20,6 +20,11 @@ export default async (req, res) => { bg_color, theme, show_owner, + show, + show_icons, + number_format, + text_bold, + line_height, cache_seconds, locale, border_radius, @@ -53,8 +58,33 @@ export default async (req, res) => { ); } + const safePattern = /^[\w\/.]+$/; + if ( + (username && !safePattern.test(username)) || + (repos && !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), @@ -81,6 +111,12 @@ export default async (req, res) => { border_radius, border_color, show_owner: parseBoolean(show_owner), + show: showStats, + show_icons, + number_format, + text_bold, + line_height, + username, locale: locale ? locale.toLowerCase() : null, description_lines_count, }), diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index bbfda52d47778..fa7212a652033 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -12,9 +12,10 @@ import { wrapTextMultiline, iconWithLabel, createLanguageNode, - clampValue, + 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; @@ -64,6 +65,11 @@ const renderRepoCard = (repo, options = {}) => { isTemplate, starCount, forkCount, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, } = repo; const { hide_border = false, @@ -72,6 +78,12 @@ const renderRepoCard = (repo, options = {}) => { text_color, bg_color, show_owner = false, + show = [], + show_icons, + number_format, + text_bold, + line_height = 10, + username, theme = "default_repocard", border_radius, border_color, @@ -79,6 +91,73 @@ const renderRepoCard = (repo, options = {}) => { description_lines_count, } = options; + let repoFilter = 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: 29.01, + bold: text_bold, + number_format, + link: STATS[key].link, + }), + ); + + const extraLHeight = parseInt(String(line_height), 10); const lineHeight = 10; const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; @@ -101,9 +180,11 @@ const renderRepoCard = (repo, options = {}) => { .map((line) => `${encodeHTML(line)}`) .join(""); + const extraHeight=45 + (statItems.length + 1) * extraLHeight; const height = (descriptionLinesCount > 1 ? 120 : 110) + - descriptionLinesCount * lineHeight; + descriptionLinesCount * lineHeight + + extraHeight; const i18n = new I18n({ locale, @@ -184,9 +265,17 @@ const renderRepoCard = (repo, options = {}) => { ${descriptionSvg} - + ${starAndForkCount} + + + ${flexLayout({ + items: statItems, + gap: extraLHeight, + direction: "column", + }).join("")} + `); }; diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 420157a0dc5ff..34a6e54d05038 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -207,7 +207,7 @@ const getStyles = ({ * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ -const renderStatsCard = (stats, options = {}, username, repos=[], orgs=[]) => { +const renderStatsCard = (stats, options = {}, username, repos=[], owners=[]) => { const { name, totalStars, @@ -351,7 +351,7 @@ const renderStatsCard = (stats, options = {}, username, repos=[], orgs=[]) => { }; } - let repoFilter = buildSearchFilter(repos, orgs); + let repoFilter = buildSearchFilter(repos, owners); if (show.includes("prs_authored")) { STATS.prs_authored = { icon: icons.prs, @@ -605,5 +605,5 @@ const renderStatsCard = (stats, options = {}, username, repos=[], orgs=[]) => { `); }; -export { renderStatsCard }; +export { renderStatsCard, createTextNode }; export default renderStatsCard; diff --git a/src/common/utils.js b/src/common/utils.js index 77ca6071cd0c0..2fdd2896b9549 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -217,14 +217,14 @@ const fallbackColor = (color, fallbackColor) => { ); }; -const buildSearchFilter = (repos = [], orgs = []) => { +const buildSearchFilter = (repos = [], owners = []) => { let repoFilter = Array.isArray(repos) && repos.length > 0 ? repos.map((r) => `repo%3A${encodeURIComponent(r)}`).join("+") + "+" : ""; let orgFilter = - Array.isArray(orgs) && orgs.length > 0 - ? orgs.map((o) => `org%3A${encodeURIComponent(o)}`).join("+") + "+" + Array.isArray(owners) && owners.length > 0 + ? owners.map((o) => `owner%3A${encodeURIComponent(o)}`).join("+") + "+" : ""; return repoFilter + orgFilter; }; diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 6438f8895cfb6..4c911390dd0ac 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,12 @@ 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, +) => { if (!username && !reponame) { throw new MissingParamError(["username", "repo"], urlExample); } @@ -95,7 +101,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, + 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 +125,18 @@ const fetchRepo = async (username, reponame) => { ) { throw new Error("Organization Repository Not found"); } + let repoUserStats = await fetchRepoUserStats( + username, + 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 a97698e8dc28a..01f1226ecb363 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -168,7 +168,7 @@ 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 totalItemsFetcher = async (username, repos, orgs, type, filter) => { +const totalItemsFetcher = async (username, repos, owners, type, filter) => { if (!githubUsernameRegex.test(username)) { logger.log("Invalid username provided."); throw new Error("Invalid username provided."); @@ -182,7 +182,7 @@ const totalItemsFetcher = async (username, repos, orgs, type, filter) => { `https://api.github.com/search/` + type + `?per_page=1&q=` + - buildSearchFilter(variables.repos, variables.orgs)+ + buildSearchFilter(variables.repos, variables.owners)+ filter, headers: { "Content-Type": "application/json", @@ -194,7 +194,7 @@ const totalItemsFetcher = async (username, repos, orgs, type, filter) => { let res; try { - res = await retryer(fetchTotalItems, { login: username, repos, orgs }); + res = await retryer(fetchTotalItems, { login: username, repos, owners }); } catch (err) { logger.log(err); throw new Error(err); @@ -210,6 +210,65 @@ const totalItemsFetcher = async (username, repos, orgs, type, filter) => { 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. */ @@ -233,7 +292,7 @@ const fetchStats = async ( include_discussions = false, include_discussions_answers = false, repos=[], - orgs=[], + owners=[], include_prs_authored = false, include_prs_commented = false, include_prs_reviewed = false, @@ -301,58 +360,24 @@ const fetchStats = async ( stats.totalCommits = await totalItemsFetcher( username, repos, - orgs, + owners, "commits", `author:${username}`, ); } else { stats.totalCommits = user.contributionsCollection.totalCommitContributions; } - if (include_prs_authored) { - stats.totalPRsAuthored = await totalItemsFetcher( - username, - repos, - orgs, - "issues", - `author:${username}+type:pr`, - ); - } - if (include_prs_commented) { - stats.totalPRsCommented = await totalItemsFetcher( - username, - repos, - orgs, - "issues", - `commenter:${username}+-author:${username}+type:pr`, - ); - } - if (include_prs_reviewed) { - stats.totalPRsReviewed = await totalItemsFetcher( - username, - repos, - orgs, - "issues", - `reviewed-by:${username}+-author:${username}+type:pr`, - ); - } - if (include_issues_authored) { - stats.totalIssuesAuthored = await totalItemsFetcher( - username, - repos, - orgs, - "issues", - `author:${username}+type:issue`, - ); - } - if (include_issues_commented) { - stats.totalIssuesCommented = await totalItemsFetcher( - username, - repos, - orgs, - "issues", - `commenter:${username}+-author:${username}+type:issue`, - ); - } + 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) { @@ -397,5 +422,5 @@ const fetchStats = async ( return stats; }; -export { fetchStats }; +export { fetchStats, fetchRepoUserStats }; export default fetchStats; diff --git a/src/translations.js b/src/translations.js index 4baab4037bcdc..83983fb2b22ef 100644 --- a/src/translations.js +++ b/src/translations.js @@ -340,6 +340,21 @@ const statCardLocales = ({ name, apostrophe }) => { "statcard.issues-commented": { en: "Issues Commented", }, + "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", + }, "statcard.prs-merged": { ar: "مجموع الطلبات المدمجة", cn: "合并的 PR 总数", From 6fe3bbb12f1781392acb9c2ae1cee478550c1c53 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 19:36:28 +0000 Subject: [PATCH 05/29] fixes, style --- api/pin.js | 23 ++++++++----- src/cards/repo-card.js | 75 ++++++++++++++++++++++------------------- src/fetchers/types.d.ts | 5 +++ 3 files changed, 59 insertions(+), 44 deletions(-) diff --git a/api/pin.js b/api/pin.js index 5ab745a2b9880..42dcac41a13e6 100644 --- a/api/pin.js +++ b/api/pin.js @@ -2,7 +2,8 @@ import { renderRepoCard } from "../src/cards/repo-card.js"; import { blacklist } from "../src/common/blacklist.js"; import { clampValue, - CONSTANTS, parseArray, + CONSTANTS, + parseArray, parseBoolean, renderError, } from "../src/common/utils.js"; @@ -61,16 +62,20 @@ export default async (req, res) => { const safePattern = /^[\w\/.]+$/; if ( (username && !safePattern.test(username)) || - (repos && !safePattern.test(repo)) + (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, - }), + renderError( + "Something went wrong", + "Username or repository contains unsafe characters", + { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + ), ); } diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index fa7212a652033..174e25758921c 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -12,10 +12,11 @@ import { wrapTextMultiline, iconWithLabel, createLanguageNode, - clampValue, buildSearchFilter, + clampValue, + buildSearchFilter, } from "../common/utils.js"; import { repoCardLocales } from "../translations.js"; -import {createTextNode} from "./stats-card.js"; +import { createTextNode } from "./stats-card.js"; const ICON_SIZE = 16; const DESCRIPTION_LINE_WIDTH = 59; @@ -91,6 +92,11 @@ const renderRepoCard = (repo, options = {}) => { description_lines_count, } = options; + const i18n = new I18n({ + locale, + translations: repoCardLocales, + }); + let repoFilter = buildSearchFilter([nameWithOwner], []); const STATS = {}; if (show.includes("prs_authored")) { @@ -139,23 +145,22 @@ const renderRepoCard = (repo, options = {}) => { }; } - 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: 29.01, - bold: text_bold, - number_format, - link: STATS[key].link, - }), - ); + 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: 29.01, + bold: text_bold, + number_format, + link: STATS[key].link, + }), + ); const extraLHeight = parseInt(String(line_height), 10); const lineHeight = 10; @@ -180,16 +185,13 @@ const renderRepoCard = (repo, options = {}) => { .map((line) => `${encodeHTML(line)}`) .join(""); - const extraHeight=45 + (statItems.length + 1) * extraLHeight; + const extraHeight = Object.keys(STATS).length + ? 45 + (statItems.length + 1) * extraLHeight + : 0; const height = (descriptionLinesCount > 1 ? 120 : 110) + - descriptionLinesCount * lineHeight - + extraHeight; - - const i18n = new I18n({ - locale, - translations: repoCardLocales, - }); + descriptionLinesCount * lineHeight + + extraHeight; // returns theme based colors with proper overrides and defaults const colors = getCardColors({ @@ -230,6 +232,16 @@ const renderRepoCard = (repo, options = {}) => { gap: 25, }).join(""); + const extraItems = ` + + ${flexLayout({ + items: statItems, + gap: extraLHeight, + direction: "column", + }).join("")} + + `; + const card = new Card({ defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, titlePrefixIcon: icons.contribs, @@ -268,14 +280,7 @@ const renderRepoCard = (repo, options = {}) => { ${starAndForkCount} - - - ${flexLayout({ - items: statItems, - gap: extraLHeight, - direction: "column", - }).join("")} - + ${extraItems} `); }; diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts index 942e3af329977..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 = { From 972d3ceb15118cf85b25d5688b3eac005bd30e00 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:14:04 +0200 Subject: [PATCH 06/29] add '-' to safe characters --- api/index.js | 2 +- api/pin.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/index.js b/api/index.js index a7da4bed4a4d5..34ecff8245a6a 100644 --- a/api/index.js +++ b/api/index.js @@ -67,7 +67,7 @@ export default async (req, res) => { ); } - const safePattern = /^[\w\/.]+$/; + const safePattern = /^[\w\/.-]+$/; if ( (username && !safePattern.test(username)) || (repos && !safePattern.test(repos)) || diff --git a/api/pin.js b/api/pin.js index 42dcac41a13e6..69c86f0b7d840 100644 --- a/api/pin.js +++ b/api/pin.js @@ -59,7 +59,7 @@ export default async (req, res) => { ); } - const safePattern = /^[\w\/.]+$/; + const safePattern = /^[\w\/.-]+$/; if ( (username && !safePattern.test(username)) || (repo && !safePattern.test(repo)) From 29fbc1c938d097e107ce3ab5c8326e3ae5157fce Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:16:45 +0200 Subject: [PATCH 07/29] add ',' to safe characters --- api/index.js | 2 +- api/pin.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/index.js b/api/index.js index 34ecff8245a6a..ef1aa738c292b 100644 --- a/api/index.js +++ b/api/index.js @@ -67,7 +67,7 @@ export default async (req, res) => { ); } - const safePattern = /^[\w\/.-]+$/; + const safePattern = /^[-\w\/.,]+$/; if ( (username && !safePattern.test(username)) || (repos && !safePattern.test(repos)) || diff --git a/api/pin.js b/api/pin.js index 69c86f0b7d840..3e6ada93e6b8a 100644 --- a/api/pin.js +++ b/api/pin.js @@ -59,7 +59,7 @@ export default async (req, res) => { ); } - const safePattern = /^[\w\/.-]+$/; + const safePattern = /^[-\w\/.,]+$/; if ( (username && !safePattern.test(username)) || (repo && !safePattern.test(repo)) From c4d7c3e4907c6c4de3409d35dc232a2c1a3898e1 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:24:30 +0200 Subject: [PATCH 08/29] fix translations --- src/translations.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/translations.js b/src/translations.js index 83983fb2b22ef..f7dc83a933968 100644 --- a/src/translations.js +++ b/src/translations.js @@ -340,21 +340,6 @@ const statCardLocales = ({ name, apostrophe }) => { "statcard.issues-commented": { en: "Issues Commented", }, - "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", - }, "statcard.prs-merged": { ar: "مجموع الطلبات المدمجة", cn: "合并的 PR 总数", @@ -481,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 = { From b6a17b05d7542d94ca9b96d00e8454a9f094643d Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:32:44 +0200 Subject: [PATCH 09/29] fix number_format --- src/cards/repo-card.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 174e25758921c..173c494c9a786 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -81,7 +81,7 @@ const renderRepoCard = (repo, options = {}) => { show_owner = false, show = [], show_icons, - number_format, + number_format = "short", text_bold, line_height = 10, username, From 213ba179559dd25cced9e62cda4a0e3da6e6b54a Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:45:04 +0200 Subject: [PATCH 10/29] fix css --- src/cards/repo-card.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 173c494c9a786..34174d7dd26d9 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -257,9 +257,26 @@ 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: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + } + @supports(-moz-appearance: auto) { + /* Selector detects Firefox */ + .stat { font-size:12px; } + } + .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: ${show_icons ? "block" : "none"}; + } `); return card.render(` From 4ce98f81c200084ace0e25377c6a15e76f105f6b Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 21 May 2025 22:46:46 +0200 Subject: [PATCH 11/29] fix css: textcolor --- src/cards/repo-card.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 34174d7dd26d9..1918831501b62 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -261,7 +261,7 @@ const renderRepoCard = (repo, options = {}) => { .badge rect { opacity: 0.2 } .stat { - font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor}; } @supports(-moz-appearance: auto) { /* Selector detects Firefox */ From 8e1b09d965fae80c38e96e9526911ff2ba735862 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 12:46:03 +0200 Subject: [PATCH 12/29] repo card: show_icons and other style issues --- api/pin.js | 4 ++-- src/cards/repo-card.js | 12 +++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/api/pin.js b/api/pin.js index 3e6ada93e6b8a..7323bc54d8dc6 100644 --- a/api/pin.js +++ b/api/pin.js @@ -117,9 +117,9 @@ export default async (req, res) => { border_color, show_owner: parseBoolean(show_owner), show: showStats, - show_icons, + show_icons: parseBoolean(show_icons), number_format, - text_bold, + text_bold: parseBoolean(text_bold), line_height, username, locale: locale ? locale.toLowerCase() : null, diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 1918831501b62..2c652545d74bb 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -80,7 +80,7 @@ const renderRepoCard = (repo, options = {}) => { bg_color, show_owner = false, show = [], - show_icons, + show_icons = true, number_format = "short", text_bold, line_height = 10, @@ -260,13 +260,7 @@ const renderRepoCard = (repo, options = {}) => { .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } - .stat { - font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor}; - } - @supports(-moz-appearance: auto) { - /* Selector detects Firefox */ - .stat { font-size:12px; } - } + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .stagger { opacity: 0; animation: fadeInAnimation 0.3s ease-in-out forwards; @@ -275,7 +269,7 @@ const renderRepoCard = (repo, options = {}) => { .bold { font-weight: 700 } .icon { fill: ${colors.iconColor}; - display: ${show_icons ? "block" : "none"}; + display: block; } `); From 74a4682e293fd581d72b7d5f69882ff5416641ff Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 14:14:35 +0200 Subject: [PATCH 13/29] repo card: fix y translation of new items --- src/cards/repo-card.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 2c652545d74bb..458aa3a842c10 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -233,13 +233,13 @@ const renderRepoCard = (repo, options = {}) => { }).join(""); const extraItems = ` - + ${flexLayout({ items: statItems, gap: extraLHeight, direction: "column", }).join("")} - + `; const card = new Card({ From 954607c15de2e348612efd5eab6e9ce04907682f Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 14:54:53 +0200 Subject: [PATCH 14/29] repo card: lowercase labels, fix extra height --- src/cards/repo-card.js | 2 +- src/translations.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 458aa3a842c10..d902f4d7892ed 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -186,7 +186,7 @@ const renderRepoCard = (repo, options = {}) => { .join(""); const extraHeight = Object.keys(STATS).length - ? 45 + (statItems.length + 1) * extraLHeight + ? -7 + (statItems.length + 1) * extraLHeight : 0; const height = (descriptionLinesCount > 1 ? 120 : 110) + diff --git a/src/translations.js b/src/translations.js index f7dc83a933968..fe03b60d6ae15 100644 --- a/src/translations.js +++ b/src/translations.js @@ -467,19 +467,19 @@ const repoCardLocales = { se: "Arkiverade", }, "repocard.prs-authored": { - en: "my Created PRs", + en: "my created PRs", }, "repocard.prs-commented": { - en: "my Commented PRs", + en: "my commented PRs", }, "repocard.prs-reviewed": { - en: "my Reviewed PRs", + en: "my reviewed PRs", }, "repocard.issues-authored": { - en: "my Created Issues", + en: "my created issues", }, "repocard.issues-commented": { - en: "my Commented Issues", + en: "my commented issues", }, }; From 5cf5a7572ea5893522f60950e75335809e9c8b07 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 15:21:08 +0200 Subject: [PATCH 15/29] repo card: adjust label offset --- src/cards/repo-card.js | 3 ++- src/cards/stats-card.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index d902f4d7892ed..b36dfb9e937fd 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -83,7 +83,7 @@ const renderRepoCard = (repo, options = {}) => { show_icons = true, number_format = "short", text_bold, - line_height = 10, + line_height = 22, username, theme = "default_repocard", border_radius, @@ -159,6 +159,7 @@ const renderRepoCard = (repo, options = {}) => { bold: text_bold, number_format, link: STATS[key].link, + labelXOffset: 20, }), ); diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 34a6e54d05038..c071afce74992 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -48,12 +48,13 @@ const createTextNode = ({ 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 ? ` From 0b43c8bb76a973a684e81fb8f638490196b2e2a2 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 15:49:30 +0200 Subject: [PATCH 16/29] repo card: adjust icon X position --- src/cards/repo-card.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index b36dfb9e937fd..9fc364229e731 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -159,7 +159,7 @@ const renderRepoCard = (repo, options = {}) => { bold: text_bold, number_format, link: STATS[key].link, - labelXOffset: 20, + labelXOffset: 23, }), ); @@ -234,7 +234,7 @@ const renderRepoCard = (repo, options = {}) => { }).join(""); const extraItems = ` - + ${flexLayout({ items: statItems, gap: extraLHeight, From f3bb62bde310bdeff32c434322ffbc1ee5c34b2c Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 17:34:22 +0200 Subject: [PATCH 17/29] repo card: custom card_width --- api/pin.js | 2 ++ src/cards/repo-card.js | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/pin.js b/api/pin.js index 7323bc54d8dc6..82c3322ca83f8 100644 --- a/api/pin.js +++ b/api/pin.js @@ -19,6 +19,7 @@ export default async (req, res) => { icon_color, text_color, bg_color, + card_width, theme, show_owner, show, @@ -115,6 +116,7 @@ export default async (req, res) => { theme, border_radius, border_color, + card_width: parseInt(card_width, 10), show_owner: parseBoolean(show_owner), show: showStats, show_icons: parseBoolean(show_icons), diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 9fc364229e731..3b10ce2d4951b 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -78,6 +78,7 @@ const renderRepoCard = (repo, options = {}) => { icon_color, text_color, bg_color, + card_width = 400, show_owner = false, show = [], show_icons = true, @@ -175,7 +176,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 @@ -246,7 +247,7 @@ const renderRepoCard = (repo, options = {}) => { 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, From 0491506565417e1a178ee35b50bf3ea270862318 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 17:50:55 +0200 Subject: [PATCH 18/29] repo card: fixes for custom card_width --- src/cards/repo-card.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 3b10ce2d4951b..b7432eead7357 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -29,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) => ` + { icon_color, text_color, bg_color, - card_width = 400, + card_width_input, show_owner = false, show = [], show_icons = true, @@ -93,6 +93,12 @@ const renderRepoCard = (repo, options = {}) => { description_lines_count, } = options; + const card_width = card_width_input + ? isNaN(card_width_input) + ? 400 + : card_width_input + : 400; + const i18n = new I18n({ locale, translations: repoCardLocales, @@ -279,10 +285,10 @@ const renderRepoCard = (repo, options = {}) => { ${ 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) : "" } From a0e588ca78f2478c74df1c40282229e82e15dcfe Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 17:54:16 +0200 Subject: [PATCH 19/29] repo card: fix v2 for custom card_width --- api/pin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pin.js b/api/pin.js index 82c3322ca83f8..2570eeb653838 100644 --- a/api/pin.js +++ b/api/pin.js @@ -116,7 +116,7 @@ export default async (req, res) => { theme, border_radius, border_color, - card_width: parseInt(card_width, 10), + card_width_input: parseInt(card_width, 10), show_owner: parseBoolean(show_owner), show: showStats, show_icons: parseBoolean(show_icons), From 33c0895e8f75f7b1fa66749a1d83e137a65f07f5 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 19:47:35 +0200 Subject: [PATCH 20/29] repo card: 2 column layout --- src/cards/repo-card.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index b7432eead7357..434dd6f749243 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -93,11 +93,9 @@ const renderRepoCard = (repo, options = {}) => { description_lines_count, } = options; - const card_width = card_width_input - ? isNaN(card_width_input) - ? 400 - : card_width_input - : 400; + const card_width = card_width_input && !isNaN(card_width_input) + ? card_width_input + : (show.length >= 2 ? 430 : 400); const i18n = new I18n({ locale, @@ -162,7 +160,7 @@ const renderRepoCard = (repo, options = {}) => { unitSymbol: STATS[key].unitSymbol, index, showIcons: show_icons, - shiftValuePos: 29.01, + shiftValuePos: 14.01, bold: text_bold, number_format, link: STATS[key].link, @@ -194,7 +192,7 @@ const renderRepoCard = (repo, options = {}) => { .join(""); const extraHeight = Object.keys(STATS).length - ? -7 + (statItems.length + 1) * extraLHeight + ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight : 0; const height = (descriptionLinesCount > 1 ? 120 : 110) + @@ -240,10 +238,20 @@ const renderRepoCard = (repo, options = {}) => { gap: 25, }).join(""); + let extraRows = []; + for (let i = 0; i < statItems; i += 2) { + extraRows.push( + flexLayout({ + items: statItems.slice(i, i + 2), + gap: 210, + direction: "row", + }).join("") + ); + } const extraItems = ` ${flexLayout({ - items: statItems, + items: extraRows, gap: extraLHeight, direction: "column", }).join("")} From 1ab6311a397451a20e45c71c1bd45d4f42e41c8f Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 22 May 2025 18:00:53 +0000 Subject: [PATCH 21/29] fix bug, types and style --- src/cards/repo-card.js | 27 +++++++++++++++++++-------- src/cards/types.d.ts | 7 +++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 434dd6f749243..7654a93db1b44 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -83,7 +83,7 @@ const renderRepoCard = (repo, options = {}) => { show = [], show_icons = true, number_format = "short", - text_bold, + text_bold = false, line_height = 22, username, theme = "default_repocard", @@ -93,9 +93,12 @@ 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 card_width = + card_width_input && !isNaN(card_width_input) + ? card_width_input + : show.length >= 2 + ? 430 + : 400; const i18n = new I18n({ locale, @@ -239,13 +242,13 @@ const renderRepoCard = (repo, options = {}) => { }).join(""); let extraRows = []; - for (let i = 0; i < statItems; i += 2) { + for (let i = 0; i < statItems.length; i += 2) { extraRows.push( flexLayout({ items: statItems.slice(i, i + 2), gap: 210, direction: "row", - }).join("") + }).join(""), ); } const extraItems = ` @@ -293,10 +296,18 @@ const renderRepoCard = (repo, options = {}) => { ${ isTemplate ? // @ts-ignore - getBadgeSVG(i18n.t("repocard.template"), colors.textColor, card_width - 400) + getBadgeSVG( + i18n.t("repocard.template"), + colors.textColor, + card_width - 400, + ) : isArchived ? // @ts-ignore - getBadgeSVG(i18n.t("repocard.archived"), colors.textColor, card_width - 400) + getBadgeSVG( + i18n.t("repocard.archived"), + colors.textColor, + card_width - 400, + ) : "" } 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 & { From aaa7a407f1eba11526ff609fb053d7c7a2e02529 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 23 May 2025 10:45:47 +0200 Subject: [PATCH 22/29] repo card: provide owner as part of repo param --- src/fetchers/repo-fetcher.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 4c911390dd0ac..530200a9bedbc 100644 --- a/src/fetchers/repo-fetcher.js +++ b/src/fetchers/repo-fetcher.js @@ -76,6 +76,19 @@ const fetchRepo = async (username, reponame, include_prs_authored = 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); } @@ -86,7 +99,7 @@ const fetchRepo = async (username, reponame, include_prs_authored = false, 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; @@ -103,7 +116,7 @@ const fetchRepo = async (username, reponame, include_prs_authored = false, } let repoUserStats = await fetchRepoUserStats( username, - reponame, + [owner + "/" + reponame], [], include_prs_authored, include_prs_commented, @@ -127,7 +140,7 @@ const fetchRepo = async (username, reponame, include_prs_authored = false, } let repoUserStats = await fetchRepoUserStats( username, - reponame, + [owner + "/" + reponame], [], include_prs_authored, include_prs_commented, From 8415848d13580ffc477f97c9e47108aa20999031 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 23 May 2025 19:47:41 +0200 Subject: [PATCH 23/29] repo card: fix error with uriEncoding --- src/cards/repo-card.js | 2 +- src/cards/stats-card.js | 2 +- src/common/utils.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 7654a93db1b44..08da78a9e7594 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -105,7 +105,7 @@ const renderRepoCard = (repo, options = {}) => { translations: repoCardLocales, }); - let repoFilter = buildSearchFilter([nameWithOwner], []); + let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); const STATS = {}; if (show.includes("prs_authored")) { STATS.prs_authored = { diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index c071afce74992..fd3acc48471ba 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -352,7 +352,7 @@ const renderStatsCard = (stats, options = {}, username, repos=[], owners=[]) => }; } - let repoFilter = buildSearchFilter(repos, owners); + let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); if (show.includes("prs_authored")) { STATS.prs_authored = { icon: icons.prs, diff --git a/src/common/utils.js b/src/common/utils.js index 2fdd2896b9549..81ebbfcc5cc73 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -220,11 +220,11 @@ const fallbackColor = (color, fallbackColor) => { const buildSearchFilter = (repos = [], owners = []) => { let repoFilter = Array.isArray(repos) && repos.length > 0 - ? repos.map((r) => `repo%3A${encodeURIComponent(r)}`).join("+") + "+" + ? repos.map((r) => `repo:${r} `).join("") : ""; let orgFilter = Array.isArray(owners) && owners.length > 0 - ? owners.map((o) => `owner%3A${encodeURIComponent(o)}`).join("+") + "+" + ? owners.map((o) => `owner:${o} `).join("") : ""; return repoFilter + orgFilter; }; From 2b521ddc81579ad1408c9f171e8f92c7e7d47ec8 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 23 May 2025 18:11:03 +0000 Subject: [PATCH 24/29] repo card: fix repo and owner filter again --- src/cards/stats-card.js | 16 ++++++++++++---- src/fetchers/stats-fetcher.js | 13 ++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index fd3acc48471ba..1d3c7051cd5c3 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -8,7 +8,8 @@ import { flexLayout, getCardColors, kFormatter, - measureText, buildSearchFilter, + measureText, + buildSearchFilter, } from "../common/utils.js"; import { statCardLocales } from "../translations.js"; @@ -34,6 +35,7 @@ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290; * @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 = ({ @@ -48,7 +50,7 @@ const createTextNode = ({ bold, number_format, link, - labelXOffset=25, + labelXOffset = 25, }) => { const kValue = number_format.toLowerCase() === "long" ? value : kFormatter(value); @@ -208,7 +210,13 @@ const getStyles = ({ * @param {Partial} options The card options. * @returns {string} The stats card SVG object. */ -const renderStatsCard = (stats, options = {}, username, repos=[], owners=[]) => { +const renderStatsCard = ( + stats, + options = {}, + username, + repos = [], + owners = [], +) => { const { name, totalStars, @@ -352,7 +360,7 @@ const renderStatsCard = (stats, options = {}, username, repos=[], owners=[]) => }; } - let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); + let repoFilter = encodeURIComponent(buildSearchFilter(repos, owners)); if (show.includes("prs_authored")) { STATS.prs_authored = { icon: icons.prs, diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 01f1226ecb363..fde2aef0aeaa4 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -182,7 +182,10 @@ const totalItemsFetcher = async (username, repos, owners, type, filter) => { `https://api.github.com/search/` + type + `?per_page=1&q=` + - buildSearchFilter(variables.repos, variables.owners)+ + buildSearchFilter(variables.repos, variables.owners).replaceAll( + " ", + "+", + ) + filter, headers: { "Content-Type": "application/json", @@ -218,7 +221,7 @@ const fetchRepoUserStats = async ( include_prs_commented, include_prs_reviewed, include_issues_authored, - include_issues_commented + include_issues_commented, ) => { let stats = {}; if (include_prs_authored) { @@ -291,8 +294,8 @@ const fetchStats = async ( include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, - repos=[], - owners=[], + repos = [], + owners = [], include_prs_authored = false, include_prs_commented = false, include_prs_reviewed = false, @@ -375,7 +378,7 @@ const fetchStats = async ( include_prs_commented, include_prs_reviewed, include_issues_authored, - include_issues_commented + include_issues_commented, ); Object.assign(stats, repoUserStats); From ec6e555a6bce3dfb765bc596ce7261b0871c55bb Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 23 May 2025 18:22:07 +0000 Subject: [PATCH 25/29] accept 0 as valid item count --- src/fetchers/stats-fetcher.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index fde2aef0aeaa4..dc0003e4ac10f 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -204,7 +204,7 @@ const totalItemsFetcher = async (username, repos, owners, type, filter) => { } const totalCount = res.data.total_count; - if (!totalCount || isNaN(totalCount)) { + if (isNaN(totalCount)) { throw new CustomError( "Could not fetch data from GitHub REST API.", CustomError.GITHUB_REST_API_ERROR, From 21c4fbb3902e4ef5368bc360ffb53f5c2a4ffcd8 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sat, 24 May 2025 12:26:35 +0000 Subject: [PATCH 26/29] code style --- api/index.js | 22 +++++++++++++--------- src/fetchers/repo-fetcher.js | 13 ++++++++----- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/api/index.js b/api/index.js index ef1aa738c292b..7018af426fac1 100644 --- a/api/index.js +++ b/api/index.js @@ -74,20 +74,24 @@ export default async (req, res) => { (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, - }), + 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 repositories=parseArray(repos); - const organizations=parseArray(owners); + const repositories = parseArray(repos); + const organizations = parseArray(owners); const stats = await fetchStats( username, parseBoolean(include_all_commits), diff --git a/src/fetchers/repo-fetcher.js b/src/fetchers/repo-fetcher.js index 530200a9bedbc..f2764f175928d 100644 --- a/src/fetchers/repo-fetcher.js +++ b/src/fetchers/repo-fetcher.js @@ -70,11 +70,14 @@ const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; * @param {string} reponame GitHub repository name. * @returns {Promise} Repository data. */ -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, +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("/")) { From 327568eeded3fb819a92e938211c3d70a83f0331 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:48:44 +0200 Subject: [PATCH 27/29] update readme with new features --- readme.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/readme.md b/readme.md index 7363686f928bf..9a14eb5de32c1 100644 --- a/readme.md +++ b/readme.md @@ -89,7 +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](#filtering-by-repository) + - [Filtering by repository and owner](#filtering-by-repository-and-owner) - [Themes](#themes) - [Customization](#customization) - [GitHub Extra Pins](#github-extra-pins) @@ -162,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 @@ -176,13 +176,13 @@ 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 +### Filtering by repository and owner To exclude specific repositories, you can use the [`&exclude_repo` parameter](#stats-card-exclusive-options). -To compute your stats for only one specific repository, you can pass a query parameter `&repo=/`. This filter is supported by the following items: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` and `issues_commented`. Note that these items are not displayed by default, but [you can enable them individually](#showing-additional-individual-stats). +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 retrieved - [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.) +(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 @@ -386,13 +386,14 @@ If we don't support your language, please consider contributing! You can find mo | `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` | -| `repo` | Count only stats from the specified repository. Affects only the items `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` and `issues_commented`. | string | `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`. And/Or the following, which support the `repo` filter: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | +| `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. @@ -403,6 +404,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. | boolean | `true` | +| `line_height` | Sets the line height between text. | integer | `22` | +| `text_bold` | Uses bold text. | boolean | `false` | +| `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. `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | #### Gist Card Exclusive Options @@ -468,10 +475,14 @@ 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) + # GitHub Gist Pins GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile. @@ -661,11 +672,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\&repo=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) +![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 From 80b78a4cada4e5c8a617e6996b65f9da74f575ca Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 8 Jun 2025 11:35:22 +0200 Subject: [PATCH 28/29] allow repos param without owner, improve readme, improve bold formatting --- api/index.js | 6 +++++- readme.md | 18 ++++++++++-------- src/cards/stats-card.js | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/api/index.js b/api/index.js index 7018af426fac1..f28b0295267a2 100644 --- a/api/index.js +++ b/api/index.js @@ -90,8 +90,12 @@ export default async (req, res) => { try { const showStats = parseArray(show); - const repositories = parseArray(repos); 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), diff --git a/readme.md b/readme.md index 9a14eb5de32c1..06b1f1b91512e 100644 --- a/readme.md +++ b/readme.md @@ -178,8 +178,6 @@ To enable icons, you can pass `&show_icons=true` in the query param, like so: ### Filtering by repository and owner -To exclude specific repositories, you can use the [`&exclude_repo` parameter](#stats-card-exclusive-options). - 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.) @@ -385,14 +383,14 @@ 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` | +| `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]\ @@ -405,10 +403,10 @@ 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. | boolean | `true` | -| `line_height` | Sets the line height between text. | integer | `22` | -| `text_bold` | Uses bold text. | boolean | `false` | -| `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_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 @@ -483,6 +481,10 @@ Use [show](#repo-card-exclusive-options) query option to display the user's cont ![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. diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 1d3c7051cd5c3..e0b4f6834d299 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -75,7 +75,7 @@ const createTextNode = ({ }" ${labelOffset} y="12.5">${label}: ${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}` + From 7eb15c3f93cac8a217c0f1c0751c46ec686d0bd5 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 8 Jun 2025 09:45:16 +0000 Subject: [PATCH 29/29] code style --- api/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/index.js b/api/index.js index f28b0295267a2..6b14bf5839885 100644 --- a/api/index.js +++ b/api/index.js @@ -92,7 +92,7 @@ export default async (req, res) => { const showStats = parseArray(show); const organizations = parseArray(owners); let repositories = parseArray(repos); - repositories = repositories.map(repo => + repositories = repositories.map((repo) => repo.includes("/") ? repo : `${username}/${repo}`, );