add items and options to repo card, validate input, orgs->owners

This commit is contained in:
martin-mfg
2025-05-21 20:06:11 +02:00
parent 5ffac3b158
commit bfe9ed2799
8 changed files with 275 additions and 65 deletions
+19 -2
View File
@@ -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),
+38 -2
View File
@@ -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,
}),
+92 -3
View File
@@ -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) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
.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}
</text>
<g transform="translate(30, ${height - 75})">
<g transform="translate(30, ${height - 75 - extraHeight})">
${starAndForkCount}
</g>
<svg x="0" y="0">
${flexLayout({
items: statItems,
gap: extraLHeight,
direction: "column",
}).join("")}
</svg>
`);
};
+3 -3
View File
@@ -207,7 +207,7 @@ const getStyles = ({
* @param {Partial<StatCardOptions>} 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;
+3 -3
View File
@@ -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;
};
+29 -1
View File
@@ -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&amp;repo=REPO_NAME";
* @param {string} reponame GitHub repository name.
* @returns {Promise<RepositoryData>} 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,
};
+76 -51
View File
@@ -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;
+15
View File
@@ -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 总数",