personal-homepage/scripts/fetch-gitea.ts

414 lines
10 KiB
TypeScript

import process from 'node:process';
import type { ProjectDownload } from './fetch-seafile.ts';
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;
downloads?: ProjectDownload[];
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<typeof createGiteaClient>;
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<GiteaRepositoryResponse>(
`/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<typeof createGiteaClient>;
config: GiteaSyncConfig;
}): Promise<RecentActivity[]> {
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<GiteaActivityFeedResponse[]>(
`/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<string, number>();
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<string>();
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<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(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<T>(pathname: string, query?: Record<string, string>): Promise<T> {
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');
}