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}
+
+
`);
};
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 总数",