// gitea-sync plugin // config: { profileUrl: "https://git.example.com/username", token: "optional" } const ICONS = [ "IconCode", "IconBrandGit", "IconTerminal2", "IconServer", "IconDatabase", "IconCloud", "IconRocket", "IconPuzzle", "IconCpu", "IconBug", "IconFileCode", "IconGitBranch", "IconPackage", "IconApi", "IconBolt", "IconBrowser", "IconDevices", "IconLock", "IconWorld", "IconSettings", ]; const COLORS = [ "#F59E0B", "#EF4444", "#3B82F6", "#10B981", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", "#14B8A6", "#6366F1", ]; function titleCase(str) { return str .replace(/[-_]+/g, " ") .replace(/([a-z])([A-Z])/g, "$1 $2") .split(" ") .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(" "); } function pickRandom(arr, seed) { let hash = 0; for (let i = 0; i < seed.length; i++) hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0; return arr[Math.abs(hash) % arr.length]; } async function fetchPublicRepos(url, user, token) { const repos = []; let page = 1; const headers = {}; if (token) headers["Authorization"] = `token ${token}`; while (true) { const res = await fetch( `${url}/api/v1/repos/search?owner=${encodeURIComponent(user)}&limit=50&page=${page}&sort=updated`, { headers } ); if (!res.ok) break; const body = await res.json(); const data = body.data || []; if (!data.length) break; for (const repo of data) { if (!repo.private && !repo.fork) { repos.push({ name: repo.name, fullName: repo.full_name, description: repo.description || null, htmlUrl: repo.html_url, updatedAt: repo.updated_at, }); } } if (data.length < 50) break; page++; } return repos; } async function fetchReleases(url, repoFullName, token) { const headers = {}; if (token) headers["Authorization"] = `token ${token}`; const res = await fetch( `${url}/api/v1/repos/${repoFullName}/releases?limit=50`, { headers } ); if (!res.ok) return []; const releases = await res.json(); return (releases || []) .filter((r) => !r.draft) .map((r) => ({ id: r.id, tag: r.tag_name, name: r.name || r.tag_name, body: r.body || "", publishedAt: r.published_at || r.created_at, htmlUrl: r.html_url, })); } const DEFAULT_TEMPLATES = [ { name: "Bug Report", isDefault: true, position: 0, fields: [ { key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" }, { key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true }, { key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true }, ], }, { name: "Feature Request", isDefault: false, position: 1, fields: [ { key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" }, { key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true }, ], }, ]; async function seedTemplates(prisma, boardId) { const existing = await prisma.boardTemplate.count({ where: { boardId } }); if (existing > 0) return; await prisma.boardTemplate.createMany({ data: DEFAULT_TEMPLATES.map((t) => ({ boardId, name: t.name, fields: t.fields, isDefault: t.isDefault, position: t.position, })), }).catch(() => {}); } function parseProfileUrl(raw) { const clean = String(raw).replace(/\/+$/, ""); const parts = clean.split("/"); const user = parts.pop(); const url = parts.join("/"); return { url, user }; } async function syncRepos(ctx) { const { profileUrl, token } = ctx.config; if (!profileUrl) { ctx.logger.warn("gitea-sync: missing profileUrl in config"); return { synced: 0, error: "missing profile URL in config" }; } const { url, user } = parseProfileUrl(profileUrl); if (!url || !user) { ctx.logger.warn("gitea-sync: could not parse profile URL"); return { synced: 0, error: "invalid profile URL - expected https://instance/username" }; } const tkn = token ? String(token) : null; const repos = await fetchPublicRepos(url, user, tkn); let created = 0; let updated = 0; let changelogsCreated = 0; // track which releases we've already imported const importedReleases = await ctx.store.get("importedReleases"); const imported = new Set(Array.isArray(importedReleases) ? importedReleases : []); for (const repo of repos) { const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-"); let board = await ctx.prisma.board.findUnique({ where: { slug } }); if (board) { await ctx.prisma.board.update({ where: { slug }, data: { externalUrl: repo.htmlUrl, description: repo.description || board.description }, }); await seedTemplates(ctx.prisma, board.id); updated++; } else { const name = titleCase(repo.name); const iconName = pickRandom(ICONS, repo.name); const iconColor = pickRandom(COLORS, repo.name + "color"); board = await ctx.prisma.board.create({ data: { slug, name, description: repo.description || `Feedback for ${name}`, externalUrl: repo.htmlUrl, iconName, iconColor, }, }); await seedTemplates(ctx.prisma, board.id); created++; } // fetch releases and create changelog entries const releases = await fetchReleases(url, repo.fullName, tkn); for (const release of releases) { const releaseKey = `${repo.fullName}:${release.id}`; if (imported.has(releaseKey)) continue; const title = `${titleCase(repo.name)} ${release.name}`; // check if this changelog already exists in the database const exists = await ctx.prisma.changelogEntry.findFirst({ where: { title, boardId: board.id }, select: { id: true }, }); if (exists) { imported.add(releaseKey); continue; } const body = release.body ? `${release.body}\n\n[View release](${release.htmlUrl})` : `[View release](${release.htmlUrl})`; await ctx.prisma.changelogEntry.create({ data: { title, body, boardId: board.id, publishedAt: new Date(release.publishedAt), }, }); imported.add(releaseKey); changelogsCreated++; } } await ctx.store.set("importedReleases", [...imported]); const lastSync = new Date().toISOString(); await ctx.store.set("lastSync", lastSync); await ctx.store.set("repoCount", repos.length); ctx.logger.info({ created, updated, changelogsCreated, total: repos.length }, "gitea-sync completed"); return { synced: repos.length, created, updated, changelogsCreated }; } let syncTimer = null; function startAutoSync(ctx) { stopAutoSync(); const minutes = parseInt(ctx.config.syncInterval, 10); if (!minutes || isNaN(minutes)) return; ctx.logger.info({ intervalMinutes: minutes }, "gitea-sync: auto-sync enabled"); syncTimer = setInterval(() => { syncRepos(ctx).catch((err) => ctx.logger.warn({ error: err.message }, "gitea-sync: auto-sync failed")); }, minutes * 60 * 1000); } function stopAutoSync() { if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } } export default { routes: [ { method: "POST", path: "/sync", async handler(req, reply, ctx) { const result = await syncRepos(ctx); reply.send(result); }, }, { method: "GET", path: "/status", async handler(_req, reply, ctx) { const lastSync = await ctx.store.get("lastSync"); const repoCount = await ctx.store.get("repoCount"); reply.send({ lastSync, repoCount, profileUrl: ctx.config.profileUrl }); }, }, ], events: { async post_created(data, ctx) { ctx.logger.info({ postId: data.postId, board: data.boardSlug }, "gitea-sync: new post created"); }, }, async onEnable(ctx) { ctx.logger.info("gitea-sync plugin enabled"); if (ctx.config.profileUrl) { try { await syncRepos(ctx); } catch (err) { ctx.logger.warn({ error: err.message }, "gitea-sync: initial sync failed"); } } startAutoSync(ctx); }, async onDisable(ctx) { stopAutoSync(); ctx.logger.info("gitea-sync plugin disabled"); }, }