diff --git a/api/commits.js b/api/commits.js index 584912b..c7be225 100644 --- a/api/commits.js +++ b/api/commits.js @@ -42,15 +42,15 @@ module.exports = async (req, res) => { if (!contributionData) { try { contributionData = await fetchContributionData(username); + if (!contributionData) throw new Error("No data"); setCache(cacheKey, contributionData, CACHE_TTL); } catch (fetchErr) { console.error("Commits fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + contributionData = { totalContributions: 0, days: [] }; } } - const { totalContributions, days } = contributionData; + const { totalContributions = 0, days = [] } = contributionData; let colors = getTheme(theme); colors = applyColorOverrides(colors, { bg_color, title_color, text_color, border_color }); diff --git a/api/contribution.js b/api/contribution.js index 74fa59a..cbaef4a 100644 --- a/api/contribution.js +++ b/api/contribution.js @@ -42,15 +42,15 @@ module.exports = async (req, res) => { if (!contributionData) { try { contributionData = await fetchContributionData(username); + if (!contributionData) throw new Error("No data"); setCache(cacheKey, contributionData, CACHE_TTL); } catch (fetchErr) { console.error("Contribution fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + contributionData = { totalContributions: 0, days: [] }; } } - const { totalContributions, days } = contributionData; + const { totalContributions = 0, days = [] } = contributionData; // Calculate streaks let currentStreak = 0; diff --git a/api/issues.js b/api/issues.js index 93c4800..71ea0cf 100644 --- a/api/issues.js +++ b/api/issues.js @@ -42,27 +42,25 @@ module.exports = async (req, res) => { fetchUserIssues(username), fetchOpenIssues(username), fetchClosedIssues(username), - fetchUserProfile(username).catch(() => ({ name: username, login: username })), + fetchUserProfile(username) ]); data = { - totalIssues: totalIssuesCount, - openIssues: openIssuesCount, - closedIssues: closedIssuesCount, - profileName: profile.name || profile.login || username, + totalIssues: totalIssuesCount || 0, + openIssues: openIssuesCount || 0, + closedIssues: closedIssuesCount || 0, + profileName: profile?.name || profile?.login || username, }; setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("Issues fetch error:", fetchErr.message); - // Fallback to zeros so card still renders data = { totalIssues: 0, openIssues: 0, closedIssues: 0, profileName: username, }; - setCache(cacheKey, data, CACHE_TTL); } } diff --git a/api/languages.js b/api/languages.js index b57368a..3274b9a 100644 --- a/api/languages.js +++ b/api/languages.js @@ -67,11 +67,12 @@ module.exports = async (req, res) => { } else { langData = await fetchUserLanguages(username); } + + if (!langData) throw new Error("No data returned"); setCache(cacheKey, langData, CACHE_TTL); } catch (fetchErr) { console.error("Language fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch languages for ${username}`)); - return; + langData = { languages: [], totalRepos: 0, totalBytes: 0, totalActivity: 0 }; } } @@ -81,27 +82,27 @@ module.exports = async (req, res) => { let svg; if (cacheMode === "repos") { svg = generateLanguageDonutByReposSVG({ - languages: langData.languages, + languages: langData.languages || [], hideBorder: hide_border === "true", }); } else if (cacheMode === "commits") { svg = generateLanguageDonutByCommitsSVG({ - languages: langData.languages, + languages: langData.languages || [], hideBorder: hide_border === "true", }); } else { svg = layout === "compact" ? generateLanguageCompactSVG({ - username, languages: langData.languages, colors, + username, languages: langData.languages || [], colors, hideBorder: hide_border === "true", }) : layout === "donut" ? generateLanguageDonutSVG({ - username, languages: langData.languages, colors, + username, languages: langData.languages || [], colors, hideBorder: hide_border === "true", }) : generateLanguageSVG({ - username, languages: langData.languages, totalBytes: langData.totalBytes, colors, + username, languages: langData.languages || [], totalBytes: langData.totalBytes || 0, colors, hideBorder: hide_border === "true", maxLangs: parseInt(max_langs) || 12, }); diff --git a/api/master.js b/api/master.js index 766a5d2..b03551a 100644 --- a/api/master.js +++ b/api/master.js @@ -11,7 +11,11 @@ const { fetchContributionData, fetchUserProfile, fetchUserLanguages, - fetchTotalCommitCount + fetchTotalCommitCount, + fetchUserIssues, + fetchOpenIssues, + fetchClosedIssues, + fetchMergedPullRequests } = require("../src/github"); const { getTheme, applyColorOverrides } = require("../src/themes"); const { generateMasterCardSVG } = require("../src/svg-master"); @@ -43,16 +47,20 @@ module.exports = async (req, res) => { if (!data) { try { - const [prs, profile, contributionData, langData, totalCommits] = await Promise.all([ + const [prs, profile, contributionData, langData, totalCommits, totalIssues, openIssues, closedIssues, mergedPRCount] = await Promise.all([ fetchUserPullRequests(username), - fetchUserProfile(username).catch(() => ({ public_repos: 0 })), + fetchUserProfile(username), fetchContributionData(username), fetchUserLanguages(username), - fetchTotalCommitCount(username) + fetchTotalCommitCount(username), + fetchUserIssues(username), + fetchOpenIssues(username), + fetchClosedIssues(username), + fetchMergedPullRequests(username) ]); - // Calculate streaks - const sortedDays = [...contributionData.days].sort((a, b) => a.date.localeCompare(b.date)); + const days = contributionData?.days || []; + const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date)); let currentStreak = 0; let longestStreak = 0; let tempStreak = 0; @@ -66,9 +74,8 @@ module.exports = async (req, res) => { else tempStreak = 0; } - // Top repos by PR count const repoMap = {}; - prs.forEach(pr => { + (prs || []).forEach(pr => { if (pr.repository_url) { const name = pr.repository_url.split("/repos/")[1]; repoMap[name] = (repoMap[name] || 0) + 1; @@ -79,24 +86,43 @@ module.exports = async (req, res) => { .sort((a, b) => b.count - a.count); data = { - username: profile.login || username, - totalPRs: prs.length, - openPRs: prs.filter(pr => pr.state === "open").length, - repoCount: profile.public_repos || 0, - languages: langData.languages, - contributions: contributionData.totalContributions, + username: profile?.login || username, + totalPRs: prs?.length || 0, + openPRs: (prs || []).filter(pr => pr.state === "open").length, + mergedPRs: mergedPRCount || 0, + repoCount: profile?.public_repos || 0, + languages: langData?.languages || [], + contributions: contributionData?.totalContributions || 0, repoList, - contributionDays: contributionData.days, + contributionDays: days, currentStreak, longestStreak, - totalCommits + totalCommits, + totalIssues: totalIssues || 0, + openIssues: openIssues || 0, + closedIssues: closedIssues || 0 }; setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("Mastercard fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + data = { + username: username, + totalPRs: 0, + openPRs: 0, + mergedPRs: 0, + repoCount: 0, + languages: [], + contributions: 0, + repoList: [], + contributionDays: [], + currentStreak: 0, + longestStreak: 0, + totalCommits: 0, + totalIssues: 0, + openIssues: 0, + closedIssues: 0 + }; } } @@ -107,6 +133,7 @@ module.exports = async (req, res) => { username: data.username, totalPRs: data.totalPRs, openPRs: data.openPRs, + mergedPRs: data.mergedPRs, repoCount: data.repoCount, languages: data.languages, contributions: data.contributions, @@ -115,6 +142,9 @@ module.exports = async (req, res) => { contributionDays: data.contributionDays, currentStreak: data.currentStreak, longestStreak: data.longestStreak, + totalIssues: data.totalIssues, + openIssues: data.openIssues, + closedIssues: data.closedIssues, colors, hideBorder: hide_border === "true", }); diff --git a/api/overview.js b/api/overview.js index d8956f8..3c1e9e1 100644 --- a/api/overview.js +++ b/api/overview.js @@ -24,7 +24,7 @@ function normalizeLinesScope(value) { return value === "all" ? "all" : "recent"; } -function parseMaxPRs(value, defaultValue = 30, hardLimit = 200) { +function parseMaxPRs(value, defaultValue = 20, hardLimit = 50) { const parsed = parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { return defaultValue; @@ -47,7 +47,7 @@ module.exports = async (req, res) => { try { const scope = normalizeLinesScope(lines_scope); - const maxPRs = scope === "all" ? 0 : parseMaxPRs(max_prs, 30, 200); + const maxPRs = scope === "all" ? 50 : parseMaxPRs(max_prs, 20, 50); const cacheKey = scope === "all" ? `overview:${username.toLowerCase()}:lines_scope=all` : `overview:${username.toLowerCase()}:lines_scope=recent:max_prs=${maxPRs}`; @@ -62,36 +62,41 @@ module.exports = async (req, res) => { try { const [prs, profile, contributionData, totalIssues, totalStars] = await Promise.all([ fetchUserPullRequests(username), - fetchUserProfile(username).catch(() => ({ public_repos: 0, public_gists: 0 })), + fetchUserProfile(username), fetchContributionData(username), fetchUserIssues(username), fetchUserTotalStars(username), ]); - const linesChanged = await fetchRecentPRLinesChanged(prs, scope === "all" ? prs.length : maxPRs); + const linesChanged = await fetchRecentPRLinesChanged(prs, maxPRs); - // Count repos contributed to from PR data const reposContributed = new Set(); - prs.forEach(pr => { + (prs || []).forEach(pr => { if (pr.repository_url) { reposContributed.add(pr.repository_url.split("/repos/")[1]); } }); data = { - totalStars, - totalCommits: contributionData.totalContributions || 0, - totalPRs: prs.length, - totalIssues, - contributedTo: reposContributed.size || profile.public_repos || 0, + totalStars: totalStars || 0, + totalCommits: contributionData?.totalContributions || 0, + totalPRs: prs?.length || 0, + totalIssues: totalIssues || 0, + contributedTo: reposContributed.size || profile?.public_repos || 0, linesChanged, }; setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("Overview fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + data = { + totalStars: 0, + totalCommits: 0, + totalPRs: 0, + totalIssues: 0, + contributedTo: 0, + linesChanged: 0, + }; } } diff --git a/api/pr-stats.js b/api/pr-stats.js index 660bfa1..76ec731 100644 --- a/api/pr-stats.js +++ b/api/pr-stats.js @@ -40,7 +40,7 @@ module.exports = async (req, res) => { try { const [prs, profile, openPRCount, closedPRCount, mergedPRCount] = await Promise.all([ fetchUserPullRequests(username), - fetchUserProfile(username).catch(() => ({ name: username, login: username })), + fetchUserProfile(username), fetchOpenPullRequests(username), fetchClosedPullRequests(username), fetchMergedPullRequests(username), @@ -50,18 +50,17 @@ module.exports = async (req, res) => { data = { repoMap, - totalPRs: prs.length, - openPRs: openPRCount, - closedPRs: closedPRCount, - mergedPRs: mergedPRCount, + totalPRs: prs?.length || 0, + openPRs: openPRCount || 0, + closedPRs: closedPRCount || 0, + mergedPRs: mergedPRCount || 0, repoCount: Object.keys(repoMap).length, - profileName: profile.name || profile.login || username, + profileName: profile?.name || profile?.login || username, }; setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("PR fetch error:", fetchErr.message); - // Fallback to zeros so card still renders data = { repoMap: {}, totalPRs: 0, @@ -71,7 +70,6 @@ module.exports = async (req, res) => { repoCount: 0, profileName: username, }; - setCache(cacheKey, data, CACHE_TTL); } } diff --git a/api/profile.js b/api/profile.js index 17aaea2..ef64196 100644 --- a/api/profile.js +++ b/api/profile.js @@ -39,6 +39,7 @@ module.exports = async (req, res) => { if (!data) { try { const profile = await fetchUserProfile(username); + if (!profile) throw new Error("No profile"); data = { username: profile.login || username, name: profile.name || profile.login || username, @@ -52,8 +53,16 @@ module.exports = async (req, res) => { setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("Profile fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch profile for ${username}`)); - return; + data = { + username: username, + name: username, + bio: "", + avatarUrl: "", + createdAt: null, + publicRepos: 0, + followers: 0, + following: 0, + }; } } diff --git a/api/streak.js b/api/streak.js index 07a5abd..8d40196 100644 --- a/api/streak.js +++ b/api/streak.js @@ -41,7 +41,8 @@ module.exports = async (req, res) => { fetchContributionData(username), fetchTotalCommitCount(username), ]); - const { days, totalContributions } = contributionData; + + const { days = [], totalContributions = 0 } = contributionData || {}; const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date)); let currentStreak = 0; @@ -61,8 +62,7 @@ module.exports = async (req, res) => { setCache(cacheKey, data, CACHE_TTL); } catch (fetchErr) { console.error("Streak fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + data = { currentStreak: 0, longestStreak: 0, totalContributions: 0, totalCommits: 0 }; } } diff --git a/api/working-hours.js b/api/working-hours.js index 0eda489..6e4695e 100644 --- a/api/working-hours.js +++ b/api/working-hours.js @@ -43,16 +43,15 @@ module.exports = async (req, res) => { const commitData = await fetchUserCommitTimestamps(username); cachedData = { - totalHours: commitData.totalWorkingHours, - commitCount: commitData.commitCount, + totalHours: commitData ? commitData.totalWorkingHours : 0, + commitCount: commitData ? commitData.commitCount : 0, lastUpdated: new Date(), }; setCache(cacheKey, cachedData, CACHE_TTL); } catch (fetchErr) { console.error("Working Hours fetch error:", fetchErr.message); - res.status(200).send(errorSVG(`Could not fetch data for ${username}`)); - return; + cachedData = { totalHours: 0, commitCount: 0, lastUpdated: new Date() }; } } diff --git a/src/github.js b/src/github.js index 991609a..0ba6d83 100644 --- a/src/github.js +++ b/src/github.js @@ -9,14 +9,14 @@ const GITHUB_API = "https://api.github.com"; */ async function safeFetch(url, options = {}) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); + const timeoutId = setTimeout(() => controller.abort(), 12000); try { const res = await fetch(url, { ...options, headers: { - "User-Agent": "gitly-app", - Accept: "application/vnd.github.v3+json", + "User-Agent": "gitly-app-bot", + "Accept": options.headers?.Accept || "application/vnd.github.v3+json", ...(options.headers || {}), }, signal: controller.signal, @@ -25,7 +25,11 @@ async function safeFetch(url, options = {}) { clearTimeout(timeoutId); if (!res.ok) { - console.warn(`GitHub API warning: ${res.status} for ${url}`); + if (res.status === 403 || res.status === 429) { + console.warn(`GitHub API Rate Limit hit for ${url}`); + } else { + console.warn(`GitHub API warning: ${res.status} for ${url}`); + } return null; } return await res.json(); @@ -37,29 +41,25 @@ async function safeFetch(url, options = {}) { } /** - * Fetch all pull requests by a user across all public repos. + * Fetch all pull requests by a user. */ async function fetchUserPullRequests(username) { const perPage = 100; let page = 1; let allPRs = []; - - while (page <= 5) { - const url = `${GITHUB_API}/search/issues?q=author:${encodeURIComponent(username)}+type:pr&per_page=${perPage}&page=${page}`; - const data = await safeFetch(url); - if (!data || !data.items || data.items.length === 0) break; - - allPRs = allPRs.concat(data.items); - if (allPRs.length >= data.total_count || data.items.length < perPage) break; - page++; - } - - return allPRs; + try { + while (page <= 2) { // Limited to 2 pages to save rate limit + const url = `${GITHUB_API}/search/issues?q=author:${encodeURIComponent(username)}+type:pr&per_page=${perPage}&page=${page}`; + const data = await safeFetch(url); + if (!data || !data.items || data.items.length === 0) break; + allPRs = allPRs.concat(data.items); + if (allPRs.length >= data.total_count || data.items.length < perPage) break; + page++; + } + } catch (e) {} + return allPRs; // Returns empty array if fails } -/** - * PR counts - */ async function fetchOpenPullRequests(username) { const url = `${GITHUB_API}/search/issues?q=author:${encodeURIComponent(username)}+type:pr+state:open&per_page=1`; const data = await safeFetch(url); @@ -78,9 +78,6 @@ async function fetchMergedPullRequests(username) { return data ? (data.total_count || 0) : 0; } -/** - * Issue counts - */ async function fetchUserIssues(username) { const url = `${GITHUB_API}/search/issues?q=author:${encodeURIComponent(username)}+type:issue&per_page=1`; const data = await safeFetch(url); @@ -99,9 +96,6 @@ async function fetchClosedIssues(username) { return data ? (data.total_count || 0) : 0; } -/** - * Group pull requests by repository name. - */ function groupPRsByRepo(prs) { const repoMap = {}; if (!Array.isArray(prs)) return repoMap; @@ -113,9 +107,6 @@ function groupPRsByRepo(prs) { return repoMap; } -/** - * Fetch user profile info. - */ async function fetchUserProfile(username) { const url = `${GITHUB_API}/users/${encodeURIComponent(username)}`; const data = await safeFetch(url); @@ -123,9 +114,6 @@ async function fetchUserProfile(username) { return data; } -/** - * Fetch REAL contribution data by scraping GitHub's contribution page. - */ async function fetchContributionData(username) { const url = `https://github.com/users/${encodeURIComponent(username)}/contributions`; try { @@ -144,9 +132,6 @@ async function fetchContributionData(username) { } } -/** - * Parse GitHub contributions HTML to extract real contribution data. - */ function parseContributionHTML(html) { let totalContributions = 0; const totalMatch = html.match(/(\d[\d,]*)\s+contributions?\s+in\s+the\s+last\s+year/i); @@ -154,20 +139,19 @@ function parseContributionHTML(html) { totalContributions = parseInt(totalMatch[1].replace(/,/g, ""), 10); } - const tdRegex = /]*\bdata-date="\d{4}-\d{2}-\d{2}"[^>]*><\/td>/g; + const entryRegex = /<(?:td|rect)\b[^>]*\bdata-date="(\d{4}-\d{2}-\d{2})"[^>]*data-level="(\d+)"[^>]*>/g; const dates = []; let match; - while ((match = tdRegex.exec(html)) !== null) { + while ((match = entryRegex.exec(html)) !== null) { + const date = match[1]; + const level = parseInt(match[2], 10); const tag = match[0]; const idMatch = tag.match(/\bid="([^"]+)"/); - const dateMatch = tag.match(/\bdata-date="(\d{4}-\d{2}-\d{2})"/); - const levelMatch = tag.match(/\bdata-level="(\d+)"/); - if (!dateMatch || !levelMatch) continue; dates.push({ id: idMatch ? idMatch[1] : `day-${dates.length}`, - date: dateMatch[1], - level: parseInt(levelMatch[1], 10), + date, + level, }); } @@ -199,178 +183,71 @@ function parseContributionHTML(html) { return { totalContributions, days }; } -/** - * Fetch commit timestamps for working hours. - */ -async function fetchUserCommitTimestamps(username) { - const perPage = 100; - let page = 1; - let allCommits = []; - - while (page <= 10) { - const url = `${GITHUB_API}/search/commits?q=author:${encodeURIComponent(username)}&per_page=${perPage}&page=${page}&sort=author-date`; - const data = await safeFetch(url); - if (!data || !data.items || data.items.length === 0) break; - - for (const commit of data.items) { - if (commit.commit && commit.commit.author && commit.commit.author.date) { - allCommits.push({ - timestamp: new Date(commit.commit.author.date).getTime(), - date: commit.commit.author.date, - }); - } - } - if (data.items.length < perPage) break; - page++; - } - - if (allCommits.length === 0) { - return { totalWorkingHours: 0, commitCount: 0 }; - } - - allCommits.sort((a, b) => a.timestamp - b.timestamp); - const MAX_GAP = 5 * 60 * 60 * 1000; - let totalWorkingMs = 0; - for (let i = 0; i < allCommits.length - 1; i++) { - const gap = allCommits[i + 1].timestamp - allCommits[i].timestamp; - if (gap > 0 && gap < MAX_GAP) { - totalWorkingMs += gap; - } - } - - const totalWorkingHours = Math.round((totalWorkingMs / (1000 * 60 * 60)) * 100) / 100; - return { totalWorkingHours, commitCount: allCommits.length }; -} - -/** - * Fetch total commit count. - */ async function fetchTotalCommitCount(username) { const url = `${GITHUB_API}/search/commits?q=author:${encodeURIComponent(username)}&per_page=1`; - const data = await safeFetch(url); + const data = await safeFetch(url, { headers: { Accept: "application/vnd.github.cloak-preview" } }); return data ? (data.total_count || 0) : 0; } -/** - * Fetch total stars. - */ async function fetchUserTotalStars(username) { let page = 1; let totalStars = 0; - - while (page <= 5) { - const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?type=owner&per_page=100&page=${page}`; - const data = await safeFetch(url); - if (!data || !Array.isArray(data) || data.length === 0) break; - - for (const repo of data) { - if (!repo.fork) { - totalStars += repo.stargazers_count || 0; + try { + while (page <= 2) { + const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?type=owner&per_page=100&page=${page}`; + const data = await safeFetch(url); + if (!data || !Array.isArray(data) || data.length === 0) break; + for (const repo of data) { + if (!repo.fork) totalStars += repo.stargazers_count || 0; } + if (data.length < 100) break; + page++; } - - if (data.length < 100) break; - page++; - } - + } catch (e) {} return totalStars; } -/** - * Fetch lines changed. - */ -async function fetchRecentPRLinesChanged(prs, maxPRs = 30) { - const targetPRs = (prs || []) - .filter((pr) => pr && pr.pull_request && pr.pull_request.url) - .slice(0, maxPRs); - +async function fetchRecentPRLinesChanged(prs, maxPRs = 10) { + const targetPRs = (prs || []).filter((pr) => pr && pr.pull_request && pr.pull_request.url).slice(0, maxPRs); if (targetPRs.length === 0) return 0; - - const concurrency = 6; + const concurrency = 2; let totalChanged = 0; - for (let i = 0; i < targetPRs.length; i += concurrency) { const batch = targetPRs.slice(i, i + concurrency); - const results = await Promise.all( - batch.map(async (pr) => { - const data = await safeFetch(pr.pull_request.url); - if (!data) return 0; - return (data.additions || 0) + (data.deletions || 0); - }) - ); - + const results = await Promise.all(batch.map(async (pr) => { + const data = await safeFetch(pr.pull_request.url); + return data ? (data.additions || 0) + (data.deletions || 0) : 0; + })); totalChanged += results.reduce((sum, v) => sum + v, 0); } - return totalChanged; } -/** - * Fetch user languages. - */ async function fetchUserLanguages(username) { - let page = 1; - const langMap = {}; - let totalBytes = 0; - - while (page <= 3) { - const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&type=owner&sort=updated`; - const repos = await safeFetch(url); - if (!repos || !repos.length) break; - - const promises = repos - .filter((r) => !r.fork && r.size > 0) - .slice(0, 20) - .map(async (repo) => { - return await safeFetch(repo.languages_url) || {}; - }); - - const results = await Promise.all(promises); - - for (const langs of results) { - for (const [lang, bytes] of Object.entries(langs)) { - langMap[lang] = (langMap[lang] || 0) + bytes; - totalBytes += bytes; - } - } - - if (repos.length < 100) break; - page++; - } - - const languages = Object.entries(langMap) - .map(([name, bytes]) => ({ - name, - bytes, - percentage: totalBytes > 0 ? Math.round(((bytes / totalBytes) * 100) * 100) / 100 : 0, - })) - .sort((a, b) => b.bytes - a.bytes); - - return { languages, totalBytes }; + return await fetchUserLanguagesByRepos(username); } async function fetchUserLanguagesByRepos(username) { let page = 1; const langRepoCount = {}; let totalRepos = 0; - - while (page <= 3) { - const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&type=owner&sort=updated`; - const repos = await safeFetch(url); - if (!repos || !repos.length) break; - - for (const repo of repos) { - if (repo.fork || repo.size <= 0) continue; - const lang = repo.language; - if (lang) { - langRepoCount[lang] = (langRepoCount[lang] || 0) + 1; - totalRepos++; + try { + while (page <= 2) { + const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&type=owner&sort=updated`; + const repos = await safeFetch(url); + if (!repos || !repos.length) break; + for (const repo of repos) { + if (repo.fork) continue; + const lang = repo.language; + if (lang) { + langRepoCount[lang] = (langRepoCount[lang] || 0) + 1; + totalRepos++; + } } + if (repos.length < 100) break; + page++; } - - if (repos.length < 100) break; - page++; - } + } catch (e) {} const languages = Object.entries(langRepoCount) .map(([name, count]) => ({ @@ -380,54 +257,73 @@ async function fetchUserLanguagesByRepos(username) { })) .sort((a, b) => b.count - a.count); - return { languages, totalRepos }; + return { languages, totalRepos, totalBytes: totalRepos * 1024 }; } async function fetchUserLanguagesByCommits(username) { let page = 1; const langActivity = {}; let totalActivity = 0; - - while (page <= 3) { - const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&type=owner&sort=updated`; - const repos = await safeFetch(url); - if (!repos || !repos.length) break; - - const promises = repos - .filter((r) => !r.fork && r.size > 0) - .slice(0, 20) - .map(async (repo) => { - const langs = await safeFetch(repo.languages_url); - if (!langs) return { langs: {}, weight: 1 }; - const weight = 1 + (repo.stargazers_count || 0) * 0.1; - return { langs, weight }; - }); - - const results = await Promise.all(promises); - - for (const { langs, weight } of results) { - for (const [lang, bytes] of Object.entries(langs)) { - const activity = bytes * weight; - langActivity[lang] = (langActivity[lang] || 0) + activity; - totalActivity += activity; + try { + while (page <= 2) { + const url = `${GITHUB_API}/users/${encodeURIComponent(username)}/repos?per_page=100&page=${page}&type=owner&sort=updated`; + const repos = await safeFetch(url); + if (!repos || !repos.length) break; + for (const repo of repos) { + if (repo.fork) continue; + const lang = repo.language; + if (lang) { + const weight = 1 + (repo.stargazers_count || 0); + langActivity[lang] = (langActivity[lang] || 0) + weight; + totalActivity += weight; + } } + if (repos.length < 100) break; + page++; } - - if (repos.length < 100) break; - page++; - } + } catch (e) {} const languages = Object.entries(langActivity) - .map(([name, activity]) => ({ + .map(([name, weight]) => ({ name, - activity: Math.round(activity), - percentage: totalActivity > 0 ? Math.round((activity / totalActivity) * 10000) / 100 : 0, + activity: weight, + percentage: totalActivity > 0 ? Math.round((weight / totalActivity) * 10000) / 100 : 0, })) .sort((a, b) => b.activity - a.activity); return { languages, totalActivity }; } +async function fetchUserCommitTimestamps(username) { + const perPage = 100; + let page = 1; + let allCommits = []; + try { + while (page <= 2) { // Heavily limited + const url = `${GITHUB_API}/search/commits?q=author:${encodeURIComponent(username)}&per_page=${perPage}&page=${page}&sort=author-date`; + const data = await safeFetch(url, { headers: { Accept: "application/vnd.github.cloak-preview" } }); + if (!data || !data.items || data.items.length === 0) break; + for (const commit of data.items) { + if (commit.commit?.author?.date) { + allCommits.push({ timestamp: new Date(commit.commit.author.date).getTime() }); + } + } + if (data.items.length < perPage) break; + page++; + } + } catch (e) {} + + if (allCommits.length === 0) return { totalWorkingHours: 0, commitCount: 0 }; + allCommits.sort((a, b) => a.timestamp - b.timestamp); + const MAX_GAP = 5 * 60 * 60 * 1000; + let totalWorkingMs = 0; + for (let i = 0; i < allCommits.length - 1; i++) { + const gap = allCommits[i + 1].timestamp - allCommits[i].timestamp; + if (gap > 0 && gap < MAX_GAP) totalWorkingMs += gap; + } + return { totalWorkingHours: Math.round((totalWorkingMs / (1000 * 60 * 60)) * 100) / 100, commitCount: allCommits.length }; +} + module.exports = { fetchUserPullRequests, fetchOpenPullRequests, diff --git a/src/svg-master.js b/src/svg-master.js index 03e6e94..3428879 100644 --- a/src/svg-master.js +++ b/src/svg-master.js @@ -1,6 +1,6 @@ /** - * Master Card SVG generation - all cards merged into one dashboard SVG. - * Enhanced design with streaks, trend line, and polished visuals. + * Master Card SVG generation - Vertical Structured Dashboard. + * Clean, classic, and fited for GitHub Readme. */ function getContributionColorByLevel(level) { @@ -17,236 +17,186 @@ function generateMasterCardSVG(options) { username, totalPRs = 0, openPRs = 0, + mergedPRs = 0, repoCount = 0, languages = [], contributions = 0, totalCommits = 0, repoList = [], - visitors = 0, contributionDays = [], currentStreak = 0, longestStreak = 0, + totalIssues = 0, + openIssues = 0, + closedIssues = 0, colors, hideBorder, - cardWidth = 1000, + cardWidth = 495, } = options; const pad = 24; - const contentW = Math.max(820, cardWidth - pad * 2); - const cardHeight = 700; + const innerW = cardWidth - pad * 2; const hba = hideBorder ? `rx="10"` : `rx="10" stroke="#30363d" stroke-width="1"`; const accentColor = (colors && colors.accent_color) || "58a6ff"; - const titleColor = (colors && colors.title_color) || "e6edf3"; + const titleColor = (colors && colors.title_color) || "58a6ff"; const textColor = (colors && colors.text_color) || "c9d1d9"; + let y = 0; + let sections = ""; + // --- HEADER --- - const headerY = 0; - let svg = ` - - - - - - - - - - + sections += ` + + ${escapeXml(username)}'s GitHub Stats + + `; + y = 70; - - - - - - - ${escapeXml(username)} - Dashboard - - - - - - ${contributions.toLocaleString()} contributions - -`; - - // --- STAT CARDS (6 stats in a row) --- - const stats = [ + // --- KEY STATS ROW --- + const statW = Math.floor(innerW / 3); + const keyStats = [ { label: "Commits", value: totalCommits.toLocaleString(), color: accentColor }, - { label: "Total PRs", value: totalPRs, color: accentColor }, - { label: "Open PRs", value: openPRs, color: "f85149" }, - { label: "Repos", value: repoCount, color: "1f6feb" }, - { label: "Cur. Streak", value: currentStreak + "d", color: "f97316" }, - { label: "Best Streak", value: longestStreak + "d", color: "eab308" }, + { label: "Contributions", value: contributions.toLocaleString(), color: "39d353" }, + { label: "Repositories", value: repoCount, color: "1f6feb" }, ]; - const statGap = 10; - const statCount = stats.length; - const statW = Math.floor((contentW - statGap * (statCount - 1)) / statCount); - const statH = 78; - - stats.forEach((s, i) => { - const x = pad + i * (statW + statGap); - svg += ` - - - ${s.value} - ${s.label} + sections += ``; + keyStats.forEach((s, i) => { + sections += ` + + ${s.value} + ${s.label} `; }); + sections += ``; + y += 50; - // --- DIVIDER --- - const divY = 162; - svg += ``; - - // --- TOP REPOS (left) + TOP LANGS (right) --- - const section2Y = divY + 16; - svg += `Top Repositories`; - svg += `Top Languages`; + sections += ``; + y += 20; - const topRepos = repoList.slice(0, 5); - const maxRepoCount = Math.max(1, ...topRepos.map(r => r.count || 0)); + // --- CONTRIBUTIONS HEATMAP --- + sections += `Recent Contributions`; + y += 10; - topRepos.forEach((repo, i) => { - const y = section2Y + 18 + i * 28; - const name = String(repo.name || "unknown"); - const shortName = name.length > 24 ? `${name.slice(0, 21)}...` : name; - const pct = (repo.count || 0) / maxRepoCount; - const barW = Math.round(200 * pct); - svg += ` - ${escapeXml(shortName)} - - - ${repo.count || 0} - `; + const sortedDays = [...contributionDays].sort((a, b) => String(a.date).localeCompare(String(b.date))); + const recent = sortedDays.slice(-105); // 15 weeks + const cell = 8, gap = 3; + const step = cell + gap; + + sections += ``; + recent.forEach((d, i) => { + const col = Math.floor(i / 7); + const row = i % 7; + sections += ``; }); - // Languages + // Streak badges next to heatmap + const streakX = 15 * step + 20; + sections += ` + + Current Streak + ${currentStreak} Days + Longest Streak + ${longestStreak} Days + `; + sections += ``; + y += 90; + + // --- TOP LANGUAGES --- + sections += `Top Languages`; + y += 12; + const { getLanguageColor } = require("./languages"); const topLangs = languages.slice(0, 5); + + // Horizontal stacked bar + sections += ``; + let barX = 0; + topLangs.forEach(lang => { + const w = (lang.percentage / 100) * innerW; + if (w > 1) { + sections += `= innerW - 1 ? 'rx="5"' : ''}/>`; + barX += w; + } + }); + sections += ``; + y += 22; + + // Language legend + sections += ``; topLangs.forEach((lang, i) => { - const y = section2Y + 18 + i * 28; - const langName = String(lang.name || "Unknown"); - const langPct = Number(lang.percentage || 0); - const barW = Math.round(170 * Math.max(0, Math.min(100, langPct)) / 100); - const langColor = getLanguageColor(langName); - svg += ` - - ${escapeXml(langName)} - - - ${langPct.toFixed(1)}% + const col = i % 3; + const row = Math.floor(i / 3); + const lx = col * (innerW / 3); + const ly = row * 20; + sections += ` + + + ${escapeXml(lang.name)} ${lang.percentage.toFixed(1)}% `; }); + sections += ``; + y += topLangs.length > 3 ? 45 : 25; - // --- DIVIDER --- - const div2Y = section2Y + 18 + 5 * 28 + 8; - svg += ``; + sections += ``; + y += 20; - // --- 30 DAY TREND LINE (left) + HEATMAP (right) --- - const sortedDays = [...contributionDays].sort((a, b) => String(a.date).localeCompare(String(b.date))); - const recent = sortedDays.slice(-126); - const last30 = sortedDays.slice(-30); - - // Trend line section - const trendSectionY = div2Y + 16; - svg += `30-Day Trend`; - - const trendX = pad + 8; - const trendY = trendSectionY + 20; - const trendW = 380; - const trendH = 50; - const max30 = Math.max(...last30.map(d => d.count), 1); - const stepX = last30.length > 1 ? trendW / (last30.length - 1) : 0; - - // Grid lines - for (let i = 0; i <= 4; i++) { - const gy = trendY + trendH - (i / 4) * trendH; - svg += ``; - } - - // Area fill - let areaPath = `M${trendX},${trendY + trendH} `; - last30.forEach((d, i) => { - const x = trendX + i * stepX; - const y = trendY + trendH - (d.count / max30) * trendH; - areaPath += `L${x},${y} `; - }); - areaPath += `L${trendX + (last30.length - 1) * stepX},${trendY + trendH} Z`; - svg += ``; - - // Line - let trendPath = ""; - let trendDots = ""; - last30.forEach((d, i) => { - const x = trendX + i * stepX; - const y = trendY + trendH - (d.count / max30) * trendH; - trendPath += `${i === 0 ? "M" : "L"}${x},${y} `; - if (i === last30.length - 1 || d.count === max30) { - trendDots += `${d.date}: ${d.count}`; - } + // --- PR & ISSUES SUMMARY --- + sections += `Pull Requests & Issues`; + y += 15; + + const summaryStats = [ + { label: "Merged PRs", value: mergedPRs, color: "8b5cf6" }, + { label: "Open PRs", value: openPRs, color: "3fb950" }, + { label: "Open Issues", value: openIssues, color: "f85149" }, + { label: "Closed Issues", value: closedIssues, color: "8b949e" }, + ]; + + sections += ``; + summaryStats.forEach((s, i) => { + const lx = (i % 2) * (innerW / 2); + const ly = Math.floor(i / 2) * 35; + sections += ` + + + ${s.label} + ${s.value} + `; }); - svg += ``; - svg += trendDots; - - // Avg label - const avg30 = last30.length > 0 ? (last30.reduce((s, d) => s + d.count, 0) / last30.length).toFixed(1) : "0"; - svg += `Avg: ${avg30}/day`; - - // Heatmap section - const heatmapLabelX = pad + 420; - svg += `Activity Heatmap (${recent.length}d)`; - - const cell = 8; - const cellGap = 3; - const cellStep = cell + cellGap; - const heatmapX = heatmapLabelX; - const heatmapY = trendSectionY + 18; - - // Month labels for heatmap - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let prevMonth = -1; - let heatmapCells = ""; - recent.forEach((d, idx) => { - const col = Math.floor(idx / 7); - const row = idx % 7; - const x = heatmapX + col * cellStep; - const y = heatmapY + row * cellStep; - const fill = getContributionColorByLevel(d.level); - const count = Number(d.count || 0); - const date = escapeXml(String(d.date || "")); - - // Month label - const dt = new Date(`${d.date}T00:00:00`); - const month = dt.getMonth(); - if (row === 0 && month !== prevMonth) { - svg += `${monthNames[month]}`; - prevMonth = month; - } + sections += ``; + y += 75; + + // --- TOP REPOS --- + sections += `Top Repositories`; + y += 10; - heatmapCells += `${date}: ${count}`; + const topRepos = repoList.slice(0, 4); + sections += ``; + topRepos.forEach((repo, i) => { + const ly = i * 28; + const barMaxWidth = innerW - 140; + const barW = Math.min(barMaxWidth, (repo.count / (topRepos[0].count || 1)) * barMaxWidth); + const repoName = repo.name.length > 25 ? repo.name.substring(0, 22) + "..." : repo.name; + sections += ` + + ${escapeXml(repoName)} + + + ${repo.count} + `; }); - svg += heatmapCells; - - // Legend - const legY = heatmapY + 7 * cellStep + 12; - svg += ` - Less - - - - - - More - `; + sections += ``; + y += topRepos.length * 28 + 10; - // --- FOOTER --- - svg += ``; - svg += `Generated by Gitly`; - svg += ``; + const cardHeight = y + 20; - return svg; + return ` + + + ${sections} + Generated by Gitly +`; } module.exports = { generateMasterCardSVG, escapeXml }; diff --git a/src/svg-pr.js b/src/svg-pr.js index 5b01ec8..b246e69 100644 --- a/src/svg-pr.js +++ b/src/svg-pr.js @@ -14,23 +14,30 @@ function generatePRCardSVG(options) { const cardHeight = hdr + maxShow * rowH + 8; const hba = hideBorder ? `rx="8"` : `rx="8" stroke="#30363d" stroke-width="1"`; + const accentColor = colors.accent_color || "58a6ff"; + const titleColor = colors.title_color || "58a6ff"; + let rows = ""; repos.slice(0, maxShow).forEach(([repo, count], i) => { const y = hdr + i * rowH; - const name = repo.length > 38 ? repo.substring(0, 35) + "..." : repo; - const pct = Math.min((count / repos[0][1]) * 100, 100); + const countLabel = String(count); - const valueW = Math.max(24, countLabel.length * 9 + 6); + const valueW = Math.max(26, countLabel.length * 8 + 8); const barW = 60; - const barToValueGap = 12; + const barToValueGap = 10; const barX = cardWidth - pad - valueW - barToValueGap - barW; const valueX = cardWidth - pad; + + // Dynamically calculate max name length to prevent overlap with bar + const maxChars = Math.floor((barX - pad - 10) / 7); + const name = repo.length > maxChars ? repo.substring(0, maxChars - 3) + "..." : repo; + rows += ` ${escapeXml(name)} - - ${countLabel} + + ${countLabel} `; }); @@ -38,21 +45,22 @@ function generatePRCardSVG(options) { rows += `+${repos.length - maxShow} more`; } + // Adjusted header spacing to prevent overlap return ` - + - - - - - Pull Requests + + + + + Pull Requests - Merged: ${mergedPRs} - Closed: ${closedPRs} - Open: ${openPRs} + Merged: ${mergedPRs} + Closed: ${closedPRs} + Open: ${openPRs} ${rows} @@ -65,15 +73,18 @@ function generatePRSummarySVG(options) { const hba = hideBorder ? `rx="8"` : `rx="8" stroke="#30363d" stroke-width="1"`; const sw = (cardWidth - P * 2 - 24) / 3; + const accentColor = colors.accent_color || "58a6ff"; + const titleColor = colors.title_color || "58a6ff"; + return ` - - - Pull Requests + + + Pull Requests - ${mergedPRs}Merged - ${totalPRs}Total PRs + ${mergedPRs}Merged + ${totalPRs}Closed ${openPRs}Open `; }