275 lines
8.0 KiB
JavaScript
275 lines
8.0 KiB
JavaScript
// 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 body = release.body
|
|
? `${release.body}\n\n[View release](${release.htmlUrl})`
|
|
: `[View release](${release.htmlUrl})`;
|
|
|
|
await ctx.prisma.changelogEntry.create({
|
|
data: {
|
|
title: `${titleCase(repo.name)} ${release.name}`,
|
|
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");
|
|
},
|
|
}
|