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] 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 51091fea..a7da4bed 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 bede7d87..5ab745a2 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 bbfda52d..fa7212a6 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 420157a0..34a6e54d 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 77ca6071..2fdd2896 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 6438f889..4c911390 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 a97698e8..01f1226e 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 4baab403..83983fb2 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 总数",