sync: Redis caching, progress bar rewrite, sentry filtering, UI improvements

- Add Redis caching to all pages and uncached API routes
- Rewrite progress bar with direct DOM manipulation for search/filters/navigation
- Filter generic network errors from Sentry client reporting
- Fix non-null assertion for repo.owner in popular-repos strategy
- Remove next-nprogress-bar dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-22 12:58:16 +03:30
parent 0713607693
commit d19113795e
16 changed files with 353 additions and 151 deletions

View File

@@ -6,6 +6,7 @@ import { BrowseFilters, SearchBar, Pagination, ActiveFilters, EmptyState } from
import { SkillCard } from '@/components/SkillCard';
import { toPersianNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -108,7 +109,7 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro
{ id: 'rating', name: t('filters.sortOptions.rating') },
];
// Fetch categories hierarchically for filter dropdown with translations
// Fetch categories hierarchically for filter dropdown with translations (cached 12h)
const tCategories = await getTranslations('categories');
type HierarchicalCategory = {
id: string;
@@ -119,8 +120,14 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro
};
let categories: HierarchicalCategory[] = [];
try {
const rawCategories = await getOrSetCache(
cacheKeys.categoriesHierarchical(),
cacheTTL.categories,
async () => {
const db = createDb();
const rawCategories = await categoryQueries.getHierarchical(db);
return await categoryQueries.getHierarchical(db);
}
);
categories = rawCategories.map(parent => ({
id: parent.id,
name: tCategories(`parents.${parent.slug}`) || parent.name,

View File

@@ -32,6 +32,7 @@ import {
import { createDb, categoryQueries } from '@skillhub/db';
import { formatNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -87,11 +88,13 @@ const parentColorMap: Record<string, string> = {
'specialized': 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
// Get categories hierarchically from database
// Get categories hierarchically with Redis caching (12 hour TTL)
async function getHierarchicalCategories() {
try {
return await getOrSetCache(cacheKeys.categoriesHierarchical(), cacheTTL.categories, async () => {
const db = createDb();
return await categoryQueries.getHierarchical(db);
});
} catch (error) {
console.error('Error fetching categories:', error);
return [];

View File

@@ -18,6 +18,7 @@ import { Footer } from '@/components/Footer';
import { EarlyAccessForm } from '@/components/EarlyAccessForm';
import { createDb, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@@ -42,11 +43,13 @@ export async function generateMetadata({
async function getStats() {
try {
return await getOrSetCache(cacheKeys.pageCount('claude-plugin'), cacheTTL.pageCount, async () => {
const db = createDb();
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills);
return skillsResult[0]?.count ?? 0;
});
} catch {
return 119000;
}

View File

@@ -7,18 +7,21 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { createDb, skills, sql } from '@skillhub/db';
import { formatPromptSkillCount } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
async function getSkillCount(): Promise<string> {
try {
return await getOrSetCache(cacheKeys.pageCount('getting-started'), cacheTTL.pageCount, async () => {
const db = createDb();
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isDuplicate} = false`);
return formatPromptSkillCount(result[0]?.count ?? 16000);
});
} catch {
return '16,000+';
}

View File

@@ -6,6 +6,7 @@ import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { SkillCard } from '@/components/SkillCard';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -16,9 +17,10 @@ interface FeaturedPageProps {
searchParams: Promise<{ page?: string }>;
}
// Get featured skills with pagination
// Get featured skills with pagination and Redis caching (2 hour TTL)
async function getFeaturedSkills(page: number, limit: number) {
try {
return await getOrSetCache(cacheKeys.featuredPage(page), cacheTTL.featured, async () => {
const db = createDb();
const offset = (page - 1) * limit;
@@ -33,6 +35,7 @@ async function getFeaturedSkills(page: number, limit: number) {
}
return { skills: featuredSkills, total };
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return { skills: [], total: 0 };

View File

@@ -8,6 +8,7 @@ import { createDb, skillQueries } from '@skillhub/db';
import { toPersianNumber } from '@/lib/format-number';
import { Pagination } from '@/components/BrowseFilters';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
@@ -41,9 +42,10 @@ function formatTimeAgo(date: Date | null, locale: string): string {
return 'Just now';
}
// Get skills based on tab with pagination
// Get skills based on tab with pagination and Redis caching (30 min TTL)
async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: number) {
try {
return await getOrSetCache(cacheKeys.newSkills(tab, page), cacheTTL.newSkills, async () => {
const db = createDb();
const offset = (page - 1) * limit;
@@ -56,21 +58,24 @@ async function getSkillsForTab(tab: 'new' | 'updated', page: number, limit: numb
const total = await skillQueries.countUpdatedSkills(db);
return { skills, total };
}
});
} catch (error) {
console.error('Error fetching skills:', error);
return { skills: [], total: 0 };
}
}
// Get counts for both tabs
// Get counts for both tabs with Redis caching (30 min TTL)
async function getTabCounts() {
try {
return await getOrSetCache(cacheKeys.newSkillsCounts(), cacheTTL.newSkills, async () => {
const db = createDb();
const [newCount, updatedCount] = await Promise.all([
skillQueries.countNewSkills(db),
skillQueries.countUpdatedSkills(db),
]);
return { newCount, updatedCount };
});
} catch (error) {
console.error('Error fetching counts:', error);
return { newCount: 0, updatedCount: 0 };

View File

@@ -9,6 +9,7 @@ import { ExternalLink, Download, Package, Eye, GitFork, ArrowUpDown, FolderGit2
import Link from 'next/link';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
export const dynamic = 'force-dynamic';
@@ -49,11 +50,15 @@ export default async function OwnerPage({ params, searchParams }: OwnerPageProps
const db = createDb();
// Fetch stats, count, and repo list in parallel
// Fetch stats and repo list with caching (30 min TTL), count is dynamic per filter
const [stats, totalSkills, ownerRepos] = await Promise.all([
skillQueries.getOwnerStats(db, username),
getOrSetCache(cacheKeys.ownerStats(username), cacheTTL.owner, () =>
skillQueries.getOwnerStats(db, username)
),
skillQueries.countByOwner(db, username, activeRepo || undefined),
skillQueries.getOwnerRepos(db, username),
getOrSetCache(cacheKeys.ownerRepos(username), cacheTTL.owner, () =>
skillQueries.getOwnerRepos(db, username)
),
]);
if (stats.totalSkills === 0) {

View File

@@ -8,67 +8,61 @@ import { SkillCard } from '@/components/SkillCard';
import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db';
import { formatCompactNumber } from '@/lib/format-number';
import { getPageAlternates } from '@/lib/seo';
import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Force dynamic rendering to fetch fresh data from database
export const dynamic = 'force-dynamic';
// Get stats directly from database
// Get stats with Redis caching (1 hour TTL)
async function getStats() {
try {
return await getOrSetCache(cacheKeys.homeStats(), cacheTTL.stats, async () => {
const db = createDb();
// Browse-ready filter: exclude duplicates (matches browseReadyFilter in queries.ts)
const browseReady = sql`${skills.isDuplicate} = false`;
// Run all independent count queries in parallel
const [skillsResult, downloadsResult, categories, contributorsResult, totalIndexedResult] = await Promise.all([
// Get total skills count (browse-ready, SKILL.md only)
const skillsResult = await db
.select({ count: sql<number>`count(*)::int` })
db.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`);
const totalSkills = skillsResult[0]?.count ?? 0;
.where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady}`),
// Get total downloads (ALL skills — downloads are real user actions)
const downloadsResult = await db
.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills);
const totalDownloads = downloadsResult[0]?.sum ?? 0;
db.select({ sum: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int` })
.from(skills),
// Get total categories
const categories = await categoryQueries.getAll(db);
const totalCategories = categories.length;
categoryQueries.getAll(db),
// Get unique contributors (browse-ready skills only)
const contributorsResult = await db
.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
db.select({ count: sql<number>`count(distinct ${skills.githubOwner})::int` })
.from(skills)
.where(browseReady);
const totalContributors = contributorsResult[0]?.count ?? 0;
.where(browseReady),
// Get total indexed skills (all, before curation) for curation note
const totalIndexedResult = await db
.select({ count: sql<number>`count(*)::int` })
db.select({ count: sql<number>`count(*)::int` })
.from(skills)
.where(sql`${skills.isBlocked} = false`);
const totalIndexed = totalIndexedResult[0]?.count ?? 0;
.where(sql`${skills.isBlocked} = false`),
]);
return {
totalSkills,
totalDownloads,
totalCategories,
totalContributors,
totalIndexed,
totalSkills: skillsResult[0]?.count ?? 0,
totalDownloads: downloadsResult[0]?.sum ?? 0,
totalCategories: categories.length,
totalContributors: contributorsResult[0]?.count ?? 0,
totalIndexed: totalIndexedResult[0]?.count ?? 0,
platforms: 5,
};
});
} catch (error) {
console.error('Error fetching stats:', error);
return null;
}
}
// Get featured skills directly from database
// Get featured skills with Redis caching (2 hour TTL)
async function getFeaturedSkills() {
try {
return await getOrSetCache(cacheKeys.homeFeatured(), cacheTTL.featured, async () => {
const db = createDb();
// Get featured skills, or top skills by popularity if none are featured
let featuredSkills = await skillQueries.getFeatured(db, 6);
@@ -77,6 +71,7 @@ async function getFeaturedSkills() {
featuredSkills = await skillQueries.getFeaturedWithDiversity(db, 6, 2, 3);
}
return featuredSkills;
});
} catch (error) {
console.error('Error fetching featured skills:', error);
return [];

View File

@@ -15,7 +15,7 @@ import { ShareButton } from '@/components/ShareButton';
import { createDb, skillQueries } from '@skillhub/db';
import { FORMAT_LABELS } from 'skillhub-core';
import { formatCompactNumber } from '@/lib/format-number';
import { shouldCountView } from '@/lib/cache';
import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
import type { Metadata } from 'next';
import { getPageAlternates } from '@/lib/seo';
@@ -46,11 +46,13 @@ interface SkillPageProps {
params: Promise<{ locale: string; id: string[] }>;
}
// Get skill directly from database
// Get skill with Redis caching (1 hour TTL)
async function getSkill(skillId: string) {
try {
return await getOrSetCache(cacheKeys.skillDetail(skillId), cacheTTL.skill, async () => {
const db = createDb();
return await skillQueries.getById(db, skillId);
});
} catch (error) {
console.error('Error fetching skill:', error);
return null;

View File

@@ -3,6 +3,7 @@ import { createDb, ratingQueries, skillQueries, userQueries } from '@skillhub/db
import { auth } from '@/lib/auth';
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
import { sanitizeReview } from '@/lib/sanitize';
import { getOrSetCache, invalidateCache, cacheKeys, cacheTTL } from '@/lib/cache';
const db = createDb();
@@ -28,10 +29,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'skillId is required' }, { status: 400 });
}
const data = await getOrSetCache(
cacheKeys.skillRatings(skillId, limit, offset),
cacheTTL.ratings,
async () => {
const ratings = await ratingQueries.getForSkill(db, skillId, limit, offset);
const skill = await skillQueries.getById(db, skillId);
return NextResponse.json({
return {
ratings: ratings.map((r) => ({
id: r.rating.id,
rating: r.rating.rating,
@@ -48,7 +52,11 @@ export async function GET(request: NextRequest) {
average: skill?.rating || 0,
count: skill?.ratingCount || 0,
},
}, {
};
}
);
return NextResponse.json(data, {
headers: createRateLimitHeaders(rateLimitResult),
});
} catch (error) {
@@ -103,6 +111,12 @@ export async function POST(request: NextRequest) {
review: sanitizeReview(review) ?? undefined,
});
// Invalidate ratings and skill detail caches
await Promise.all([
invalidateCache(cacheKeys.skillRatings(skillId, 10, 0)),
invalidateCache(cacheKeys.skillDetail(skillId)),
]);
// Get updated skill aggregates
const updatedSkill = await skillQueries.getById(db, skillId);

View File

@@ -1,14 +1,157 @@
'use client';
import { AppProgressBar } from 'next-nprogress-bar';
import { useEffect, useRef } from 'react';
export function ProgressBar() {
const barRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRunningRef = useRef(false);
const currentUrlRef = useRef('');
useEffect(() => {
const bar = barRef.current;
if (!bar) return;
currentUrlRef.current = location.pathname + location.search;
function clearTimers() {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
function start() {
clearTimers();
isRunningRef.current = true;
bar!.style.transition = 'none';
bar!.style.width = '0%';
bar!.style.opacity = '1';
// Force reflow so browser registers 0% before animating
void bar!.offsetWidth;
bar!.style.transition = 'width 2s cubic-bezier(0.1, 0.5, 0.3, 1)';
bar!.style.width = '80%';
// Safety: auto-complete after 10s
timerRef.current = setTimeout(done, 10000);
}
function done() {
clearTimers();
isRunningRef.current = false;
bar!.style.transition = 'width 200ms ease-out';
bar!.style.width = '100%';
timerRef.current = setTimeout(() => {
if (!barRef.current) return;
barRef.current.style.transition = 'opacity 300ms ease-out';
barRef.current.style.opacity = '0';
}, 250);
}
// Called when URL has changed (navigation completed)
function onUrlChange() {
const newUrl = location.pathname + location.search;
if (newUrl === currentUrlRef.current) return;
currentUrlRef.current = newUrl;
if (isRunningRef.current) {
// Bar was started by click handler — complete it
done();
} else {
// Programmatic navigation (search, filters, router.push/replace)
// Show a quick fill animation as feedback
start();
requestAnimationFrame(() => requestAnimationFrame(() => done()));
}
}
// 1. Intercept <a> tag clicks (for Link components)
const handleClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!href) return;
if (
href.startsWith('http') ||
href.startsWith('#') ||
href.startsWith('mailto:') ||
anchor.target === '_blank' ||
e.ctrlKey ||
e.metaKey ||
e.shiftKey
) {
return;
}
if (href === currentUrlRef.current) return;
start();
};
document.addEventListener('click', handleClick, true);
// 2. Monkey-patch pushState/replaceState for programmatic navigation
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function (
data: unknown,
unused: string,
url?: string | URL | null,
) {
origPushState.call(this, data, unused, url);
onUrlChange();
};
history.replaceState = function (
data: unknown,
unused: string,
url?: string | URL | null,
) {
origReplaceState.call(this, data, unused, url);
onUrlChange();
};
// 3. Back/forward navigation
const handlePopState = () => onUrlChange();
window.addEventListener('popstate', handlePopState);
return () => {
clearTimers();
document.removeEventListener('click', handleClick, true);
history.pushState = origPushState;
history.replaceState = origReplaceState;
window.removeEventListener('popstate', handlePopState);
};
}, []);
return (
<AppProgressBar
height="3px"
color="#0284c7"
options={{ showSpinner: false }}
shallowRouting
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 99999,
pointerEvents: 'none',
height: '3px',
}}
>
<div
ref={barRef}
style={{
height: '100%',
background: 'linear-gradient(90deg, #0284c7, #38bdf8)',
width: '0%',
opacity: '0',
boxShadow: '0 0 8px rgba(2, 132, 199, 0.4)',
}}
/>
</div>
);
}

View File

@@ -129,12 +129,41 @@ export async function isCacheAvailable(): Promise<boolean> {
}
}
/**
* Get cached data or fetch and cache it.
* If Redis is unavailable, falls back to fetcher directly (graceful degradation).
*/
export async function getOrSetCache<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await getCached<T>(key);
if (cached !== null) return cached;
const data = await fetcher();
// Fire-and-forget cache write
setCache(key, data, ttlSeconds).catch(() => {});
return data;
}
// Cache key builders for consistency
export const cacheKeys = {
stats: () => 'stats:global',
homeStats: () => 'page:home:stats',
homeFeatured: () => 'page:home:featured',
categories: () => 'categories:all',
categoriesHierarchical: () => 'categories:hierarchical',
featuredSkills: () => 'skills:featured',
featuredPage: (page: number) => `page:featured:${page}`,
recentSkills: () => 'skills:recent',
newSkills: (tab: string, page: number) => `page:new:${tab}:${page}`,
newSkillsCounts: () => 'page:new:counts',
ownerStats: (username: string) => `page:owner:${username}:stats`,
ownerRepos: (username: string) => `page:owner:${username}:repos`,
skillDetail: (id: string) => `page:skill:${id.replace(/\//g, ':')}`,
skillRatings: (id: string, limit: number, offset: number) => `ratings:${id.replace(/\//g, ':')}:${limit}:${offset}`,
pageCount: (page: string) => `page:${page}:count`,
searchSkills: (hash: string) => `skills:search:${hash}`,
skill: (id: string) => `skill:${id.replace(/\//g, ':')}`,
skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`,
@@ -149,6 +178,10 @@ export const cacheTTL = {
recent: 60 * 60, // 1 hour
search: 30 * 60, // 30 minutes
skill: 60 * 60, // 1 hour
newSkills: 30 * 60, // 30 minutes
owner: 30 * 60, // 30 minutes
ratings: 15 * 60, // 15 minutes
pageCount: 60 * 60, // 1 hour
view: 60 * 60, // 1 hour - same IP can only count as 1 view per hour
download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min
};

View File

@@ -27,7 +27,6 @@
"next": "^15.0.0",
"next-auth": "5.0.0-beta.30",
"next-intl": "^3.4.0",
"next-nprogress-bar": "^2.4.7",
"next-themes": "^0.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -37,6 +37,8 @@ Sentry.init({
"Error in input stream",
// Network fetch failures (Safari / mobile)
"Load failed",
// Generic network errors (client connectivity issues)
"network error",
// Browser extension noise (translation/accessibility extensions)
/Object Not Found Matching Id/,
// ResizeObserver loop limit — browser quirk, not actionable

15
pnpm-lock.yaml generated
View File

@@ -187,9 +187,6 @@ importers:
next-intl:
specifier: ^3.4.0
version: 3.26.5(next@15.5.9(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
next-nprogress-bar:
specifier: ^2.4.7
version: 2.4.7
next-themes:
specifier: ^0.4.4
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4313,9 +4310,6 @@ packages:
next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
next-nprogress-bar@2.4.7:
resolution: {integrity: sha512-OeveNQYFBhQhZ+RgrDnvHNUEQfHCmipymmD4AfAVE9pFV4jeWi7/nNK5f0lIk7ODRrtjyyr/n2YpkRbs5kUoMg==}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -4377,9 +4371,6 @@ packages:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
nprogress-v2@1.1.10:
resolution: {integrity: sha512-MypWLNIPIM07SS0bAc/oac0vhVFz9vAHm7d1sj//Pnf3J03LQ3CuWrlDteIu6exq0fIvkDJ6tUDRWLaifsIt5w==}
oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
@@ -9616,10 +9607,6 @@ snapshots:
react: 18.3.1
use-intl: 3.26.5(react@18.3.1)
next-nprogress-bar@2.4.7:
dependencies:
nprogress-v2: 1.1.10
next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
@@ -9673,8 +9660,6 @@ snapshots:
dependencies:
path-key: 4.0.0
nprogress-v2@1.1.10: {}
oauth4webapi@3.8.3: {}
object-assign@4.1.1: {}

View File

@@ -111,7 +111,7 @@ export class PopularReposCrawler {
for (const repo of response.data.items) {
if (!repo.archived) {
results.push({
owner: repo.owner.login,
owner: repo.owner!.login,
repo: repo.name,
stars: repo.stargazers_count,
forks: repo.forks_count,