import process from 'node:process'; type ProjectSeed = { name: string; description: string; gitea_repo: string; cover_image: string; demo_video?: string; download_link?: string; tags: string[]; featured: boolean; }; export type GeneratedProject = ProjectSeed & { repo_url?: string; updated_at: string; source: 'seed' | 'gitea' | 'gitea+seafile'; }; export type ActivityDay = { date: string; count: number; }; export type RecentActivity = { type: string; repo: string; message: string; url: string; time: string; }; export type GeneratedGiteaActivity = { updatedAt: string; source: 'placeholder' | 'gitea'; itemCount: number; days: ActivityDay[]; recent: RecentActivity[]; }; export type GiteaSyncConfig = { baseUrl: string; token: string; username: string; activityDays?: number; activityPerDayLimit?: number; recentItemLimit?: number; requestTimeoutMs?: number; requestConcurrency?: number; strict?: boolean; }; type GiteaRepositoryResponse = { description?: string; full_name?: string; html_url?: string; updated_at?: string; topics?: string[]; }; type GiteaActivityFeedResponse = { op_type?: string; content?: string; created?: string; ref_name?: string; repo?: { full_name?: string; name?: string; html_url?: string; }; comment?: { html_url?: string; body?: string; }; issue?: { html_url?: string; title?: string; }; pull_request?: { html_url?: string; title?: string; }; }; export async function fetchGiteaSnapshot(input: { config: GiteaSyncConfig; projectSeeds: ProjectSeed[]; syncedAt: string; }) { const { config, projectSeeds, syncedAt } = input; const client = createGiteaClient(config); const [repoResults, userActivities] = await Promise.all([ Promise.all( projectSeeds.map((seed) => enrichProjectFromGitea({ client, config, project: seed, syncedAt })), ), fetchUserActivities({ client, config }), ]); const projects = repoResults.map((result) => result.project); const activity = buildActivitySnapshot({ syncedAt, recentItemLimit: config.recentItemLimit ?? 8, activityDays: config.activityDays ?? 70, feeds: userActivities, }); return { projects, activity }; } async function enrichProjectFromGitea(input: { client: ReturnType; config: GiteaSyncConfig; project: ProjectSeed; syncedAt: string; }): Promise<{ project: GeneratedProject }> { const { client, config, project, syncedAt } = input; const repoParts = splitRepoFullName(project.gitea_repo); const fallbackRepoUrl = buildRepoUrl(config.baseUrl, project.gitea_repo); if (!repoParts) { return { project: toSeedProject(project, syncedAt, fallbackRepoUrl) }; } try { const repo = await client.getJson( `/api/v1/repos/${repoParts.owner}/${repoParts.repo}`, ); return { project: { ...project, description: repo.description?.trim() || project.description, repo_url: repo.html_url || fallbackRepoUrl, tags: mergeTags(project.tags, repo.topics ?? []), updated_at: repo.updated_at || syncedAt, source: project.download_link ? 'gitea+seafile' : 'gitea', }, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); const logLine = `[fetch-gitea] failed to sync ${project.gitea_repo}: ${message}`; if (config.strict) { throw new Error(logLine); } console.warn(logLine); return { project: toSeedProject(project, syncedAt, fallbackRepoUrl), }; } } async function fetchUserActivities(input: { client: ReturnType; config: GiteaSyncConfig; }): Promise { const { client, config } = input; const activityDays = config.activityDays ?? 70; const limit = config.activityPerDayLimit ?? 50; const concurrency = Math.max(1, config.requestConcurrency ?? 5); const dates = Array.from({ length: activityDays }, (_, index) => { const date = new Date(); date.setUTCDate(date.getUTCDate() - index); return date.toISOString().slice(0, 10); }); const results = await mapWithConcurrency(dates, concurrency, async (date) => { try { const feeds = await client.getJson( `/api/v1/users/${config.username}/activities/feeds`, { 'only-performed-by': 'true', date, limit: String(limit), }, ); return feeds.map((feed) => mapActivityFeed(feed, { fallbackRepo: config.username, fallbackRepoUrl: `${trimTrailingSlash(config.baseUrl)}/${config.username}`, }), ); } catch (error) { const message = error instanceof Error ? error.message : String(error); const logLine = `[fetch-gitea] failed to sync user activity for ${config.username} on ${date}: ${message}`; if (config.strict) { throw new Error(logLine); } console.warn(logLine); return []; } }); return results.flat(); } function buildActivitySnapshot(input: { syncedAt: string; recentItemLimit: number; activityDays: number; feeds: RecentActivity[]; }): GeneratedGiteaActivity { const sortedFeeds = [...input.feeds] .filter((item) => item.time) .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)); const recent = dedupeActivities(sortedFeeds).slice(0, input.recentItemLimit); const days = buildHeatmapDays(sortedFeeds, input.activityDays); return { updatedAt: input.syncedAt, source: sortedFeeds.length > 0 ? 'gitea' : 'placeholder', itemCount: sortedFeeds.length, days, recent, }; } function buildHeatmapDays(activities: RecentActivity[], dayCount: number): ActivityDay[] { const now = new Date(); const counts = new Map(); for (const item of activities) { const dateKey = toDateKey(item.time); if (!dateKey) { continue; } counts.set(dateKey, (counts.get(dateKey) ?? 0) + 1); } const days: ActivityDay[] = []; for (let offset = dayCount - 1; offset >= 0; offset -= 1) { const date = new Date(now); date.setUTCDate(date.getUTCDate() - offset); const dateKey = date.toISOString().slice(0, 10); days.push({ date: dateKey, count: counts.get(dateKey) ?? 0, }); } return days; } function dedupeActivities(activities: RecentActivity[]) { const seen = new Set(); return activities.filter((item) => { const key = `${item.repo}|${item.time}|${item.type}|${item.url}|${item.message}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); } function mapActivityFeed( feed: GiteaActivityFeedResponse, fallback: { fallbackRepo: string; fallbackRepoUrl: string }, ): RecentActivity { const repo = feed.repo?.full_name || fallback.fallbackRepo; const type = normalizeActivityType(feed.op_type); const message = clean(feed.content) || clean(feed.comment?.body) || clean(feed.issue?.title) || clean(feed.pull_request?.title) || [feed.op_type, feed.ref_name].filter(Boolean).join(' ').trim() || 'Repository activity'; const url = feed.comment?.html_url || feed.issue?.html_url || feed.pull_request?.html_url || feed.repo?.html_url || fallback.fallbackRepoUrl; const time = feed.created || new Date().toISOString(); return { type, repo, message, url, time, }; } async function mapWithConcurrency( items: T[], concurrency: number, mapper: (item: T, index: number) => Promise, ): Promise { const results = new Array(items.length); let nextIndex = 0; async function worker() { while (nextIndex < items.length) { const currentIndex = nextIndex; nextIndex += 1; results[currentIndex] = await mapper(items[currentIndex], currentIndex); } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); return results; } function normalizeActivityType(value?: string) { return value?.trim().toLowerCase().replace(/\s+/g, '_') || 'activity'; } function toSeedProject(project: ProjectSeed, syncedAt: string, fallbackRepoUrl: string): GeneratedProject { return { ...project, repo_url: fallbackRepoUrl, updated_at: syncedAt, source: 'seed', }; } function mergeTags(seedTags: string[], topics: string[]) { const merged = new Set([...seedTags, ...topics].map((tag) => tag.trim()).filter(Boolean)); return [...merged]; } function splitRepoFullName(value: string) { const [owner, repo] = value.split('/'); if (!owner || !repo) { return null; } return { owner, repo }; } function buildRepoUrl(baseUrl: string, repoFullName: string) { return `${trimTrailingSlash(baseUrl)}/${repoFullName}`; } function toDateKey(value: string) { const time = Date.parse(value); if (Number.isNaN(time)) { return ''; } return new Date(time).toISOString().slice(0, 10); } function clean(value?: string) { return value?.replace(/\s+/g, ' ').trim() || ''; } function trimTrailingSlash(value: string) { return value.replace(/\/+$/, ''); } function createGiteaClient(config: GiteaSyncConfig) { const headers = new Headers({ accept: 'application/json', }); if (config.token) { headers.set('Authorization', `token ${config.token}`); } return { async getJson(pathname: string, query?: Record): Promise { const url = new URL(pathname, `${trimTrailingSlash(config.baseUrl)}/`); if (query) { for (const [key, value] of Object.entries(query)) { url.searchParams.set(key, value); } } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs ?? 15000); try { const response = await fetch(url, { method: 'GET', headers, signal: controller.signal, }); if (!response.ok) { const body = await safeReadText(response); throw new Error(`HTTP ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`); } return (await response.json()) as T; } finally { clearTimeout(timeout); } }, }; } async function safeReadText(response: Response) { try { const text = await response.text(); return text.slice(0, 300); } catch { return ''; } } if (import.meta.url === `file://${process.argv[1]}`) { console.log('[fetch-gitea] This module is intended to be imported by scripts/sync-content.ts'); }