From be1fc227d068f64a55a785e0795f1a2932b923bb Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 23 Jul 2026 10:37:28 +0200 Subject: [PATCH] =?UTF-8?q?Release-Ank=C3=BCndigungen=20+=20=C3=B6ffentlic?= =?UTF-8?q?he=20Changelog-Seite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gitea-Release-Webhook: Releases werden archiviert (releases-Tabelle) und neue 'published'-Releases als oranges 🚀-Embed mit Buttons angekündigt - Release-Kanal auf der Setup-Seite wählbar (leer = Feature aus) + Test-Button - /changelog: öffentliche Seite mit Tag-Chips, Pre-Release-Badge, Release-Notes - /api/releases öffentlich; Updates/Redeliveries posten nicht doppelt Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.jsx | 3 ++ frontend/src/pages/Changelog.jsx | 76 ++++++++++++++++++++++++++++++++ frontend/src/pages/Settings.jsx | 14 ++++++ frontend/src/style.css | 19 +++++++- src/bot/release-feed.js | 50 +++++++++++++++++++++ src/db.js | 41 +++++++++++++++++ src/runtime-settings.js | 5 +++ src/web/api.js | 40 ++++++++++++----- src/web/server.js | 40 ++++++++++++++++- 9 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/Changelog.jsx create mode 100644 src/bot/release-feed.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9ce2cb9..4035c07 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Routes, Route, NavLink, Navigate } from 'react-router-dom'; import { apiGet } from './api.js'; import Devlogs from './pages/Devlogs.jsx'; +import Changelog from './pages/Changelog.jsx'; import Commits from './pages/Commits.jsx'; import Settings from './pages/Settings.jsx'; @@ -69,6 +70,7 @@ export default function App() { @@ -92,6 +94,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Changelog.jsx b/frontend/src/pages/Changelog.jsx new file mode 100644 index 0000000..27d17e9 --- /dev/null +++ b/frontend/src/pages/Changelog.jsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { apiGet } from '../api.js'; +import { Markdown } from '../markdown.jsx'; + +const dateFmt = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: 'long', year: 'numeric', +}); + +export default function Changelog() { + const [data, setData] = useState(null); + const [page, setPage] = useState(1); + const [error, setError] = useState(false); + + useEffect(() => { + setError(false); + apiGet(`/api/releases?page=${page}`).then(setData).catch(() => setError(true)); + window.scrollTo(0, 0); + }, [page]); + + const pages = data ? Math.ceil(data.total / data.pageSize) : 0; + + return ( + <> +
+
CHANGELOG
+
+ +
+ {error &&

Changelog konnte nicht geladen werden.

} + {!error && !data &&

Lade …

} + {data?.total === 0 &&

Noch keine Releases veröffentlicht.

} + + {data?.total > 0 && + data.items.map((r) => ( +
+
+ {r.tag} + {r.prerelease ? Pre-Release : null} + {r.repo.split('/').pop()} + {dateFmt.format(new Date(r.published_at))} +
+ {r.name && r.name !== r.tag &&

{r.name}

} +
+ +
+ {r.url && ( + + )} +
+ ))} + + {pages > 1 && ( +
+ + + {String(page).padStart(2, '0')} / {String(pages).padStart(2, '0')} + + +
+ )} +
+ + ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6d007c1..b0b625c 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -136,6 +136,20 @@ export default function Settings({ me }) { + +
+ +
+ + +
+
{/* Commit-Feed */} diff --git a/frontend/src/style.css b/frontend/src/style.css index 984ef56..f3aef19 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -238,7 +238,7 @@ body::after { transition: border-color .25s, background .25s; } .card:hover { border-color: var(--border); background: var(--bg3); } -.card::before { +.timeline .card::before { /* Marker auf der Timeline */ content: ''; position: absolute; left: -1.72rem; top: 1.55rem; @@ -337,6 +337,23 @@ body::after { .card-footer a { color: var(--muted); border-bottom: 1px solid transparent; } .card-footer a:hover { color: var(--neon); } +/* ── CHANGELOG ─────────────────────────────────────── */ +.release-card { margin-bottom: 1.5rem; } +.release-tag { + font-family: var(--mono); + font-size: .85rem; letter-spacing: .1em; + color: var(--neon); + border: 1px solid var(--border); + background: rgba(245, 197, 24, .06); + padding: .12rem .6rem; +} +.release-name { + font-family: var(--display); + font-size: 1.35rem; letter-spacing: .03em; + margin: .3rem 0 .2rem; + color: var(--text); +} + /* ── COMMIT-LISTE ──────────────────────────────────── */ .commit-list { border: 1px solid rgba(255, 255, 255, .06); background: var(--bg2); } .commit-row { diff --git a/src/bot/release-feed.js b/src/bot/release-feed.js new file mode 100644 index 0000000..59dfe61 --- /dev/null +++ b/src/bot/release-feed.js @@ -0,0 +1,50 @@ +// Release-Ankündigungen: großes Embed in den konfigurierten Kanal (Setup-Seite) +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { releaseChannelId } from '../runtime-settings.js'; +import { config } from '../config.js'; + +const BRAND_ORANGE = 0xff4d00; + +/** + * Release ankündigen (falls ein Kanal konfiguriert ist). + * @param {import('discord.js').Client} client + * @param {{ repo: string, tag: string, name: string|null, body: string|null, + * url: string|null, prerelease: boolean }} release + * @returns {Promise} true, wenn gepostet + */ +export async function postReleaseEmbed(client, release) { + const channelId = releaseChannelId(); + if (!channelId) return false; + + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased()) { + throw new Error(`Release-Kanal ${channelId} nicht gefunden oder kein Textkanal`); + } + + let body = (release.body ?? '').trim(); + if (body.length > 3500) body = `${body.slice(0, 3500)}\n… (gekürzt)`; + + const title = release.name?.trim() || release.tag; + const embed = new EmbedBuilder() + .setColor(BRAND_ORANGE) + .setTitle(`🚀 ${title}${release.prerelease ? ' (Pre-Release)' : ''}`) + .setDescription(body || '*Keine Release-Notes.*') + .addFields({ name: 'Version', value: `\`${release.tag}\``, inline: true }, + { name: 'Projekt', value: release.repo, inline: true }) + .setFooter({ text: 'D4RKST3R // RELEASE' }) + .setTimestamp(); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Changelog').setEmoji('📋') + .setURL(`${config.publicUrl}/changelog`) + ); + if (release.url) { + buttons.addComponents( + new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel('Release ansehen').setEmoji('🔗') + .setURL(release.url) + ); + } + + await channel.send({ embeds: [embed], components: [buttons] }); + return true; +} diff --git a/src/db.js b/src/db.js index a56a94d..9215c67 100644 --- a/src/db.js +++ b/src/db.js @@ -41,6 +41,20 @@ if (!devlogCols.some((c) => c.name === 'images')) { db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } +// Releases (Gitea-Release-Webhook) — für Ankündigungen + Changelog-Seite +db.exec(` + CREATE TABLE IF NOT EXISTS releases ( + repo TEXT NOT NULL, + tag TEXT NOT NULL, + name TEXT, + body TEXT, + url TEXT, + prerelease INTEGER NOT NULL DEFAULT 0, + published_at TEXT NOT NULL, + PRIMARY KEY (repo, tag) + ); +`); + // Volltextsuche (FTS5) über Devlogs — Trigger halten den Index synchron db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS devlogs_fts USING fts5(content, message_id UNINDEXED); @@ -170,6 +184,33 @@ export function listCommits(limit, offset) { return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n }; } +const upsertRelease = db.prepare(` + INSERT INTO releases (repo, tag, name, body, url, prerelease, published_at) + VALUES (@repo, @tag, @name, @body, @url, @prerelease, @published_at) + ON CONFLICT(repo, tag) DO UPDATE SET + name = excluded.name, body = excluded.body, url = excluded.url, + prerelease = excluded.prerelease +`); + +/** Release speichern/aktualisieren. Gibt true zurück, wenn neu (→ ankündigen). */ +export function saveRelease(release) { + const existed = db + .prepare('SELECT 1 FROM releases WHERE repo = ? AND tag = ?') + .get(release.repo, release.tag); + upsertRelease.run(release); + return !existed; +} + +const selectReleases = db.prepare(` + SELECT repo, tag, name, body, url, prerelease, published_at + FROM releases ORDER BY published_at DESC LIMIT ? OFFSET ? +`); +const countReleasesStmt = db.prepare('SELECT count(*) AS n FROM releases'); + +export function listReleases(limit, offset) { + return { items: selectReleases.all(limit, offset), total: countReleasesStmt.get().n }; +} + /** Statistiken fürs Status-Panel der Settings-Seite */ export function archiveStats() { return { diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 844e8b0..c8f762f 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -11,6 +11,11 @@ export function devlogChannelId() { return getSetting('devlog_channel_id') || config.devlogChannelId; } +/** Release-Ankündigungs-Kanal — nur per Settings-Seite, leer = Feature aus */ +export function releaseChannelId() { + return getSetting('release_channel_id') || null; +} + /** Commit-Feed global an/aus (Default: an). Aus = weiter archivieren, nur nicht posten. */ export function commitFeedEnabled() { return getSetting('commit_feed_enabled') !== '0'; diff --git a/src/web/api.js b/src/web/api.js index d41624d..ddcb2e4 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -1,9 +1,9 @@ // REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin import { EmbedBuilder } from 'discord.js'; -import { listDevlogs, searchDevlogs, listCommits, archiveStats, getSetting, setSetting } from '../db.js'; +import { listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats, getSetting, setSetting } from '../db.js'; import { config } from '../config.js'; import { removeDevlog } from '../bot/devlog-archive.js'; -import { commitChannelId, devlogChannelId } from '../runtime-settings.js'; +import { commitChannelId, devlogChannelId, releaseChannelId } from '../runtime-settings.js'; import { getSessionUser, isAdmin } from './auth.js'; const PAGE_SIZE = 20; @@ -42,6 +42,13 @@ export function registerApiRoutes(app, client) { return { items: mapped, total, page, pageSize: PAGE_SIZE }; }); + // Changelog — öffentlich (Releases aller Repos) + app.get('/api/releases', async (request) => { + const { limit, offset, page } = paging(request); + const { items, total } = listReleases(limit, offset); + return { items, total, page, pageSize: PAGE_SIZE }; + }); + // RSS-Feed der Devlogs — öffentlich abonnierbar app.get('/feed.xml', async (request, reply) => { const { items } = listDevlogs(20, 0); @@ -113,6 +120,7 @@ ${rssItems} return { commit_channel_id: commitChannelId(), devlog_channel_id: devlogChannelId(), + release_channel_id: releaseChannelId() ?? '', commit_feed_enabled: getSetting('commit_feed_enabled') !== '0', commit_branch_filter: getSetting('commit_branch_filter') ?? '', ignored_repos: getSetting('ignored_repos') ?? '', @@ -140,15 +148,19 @@ ${rssItems} const body = request.body ?? {}; - // Kanäle: müssen existierende Textkanäle sein - for (const key of ['commit_channel_id', 'devlog_channel_id']) { - if (body[key] !== undefined) { - const ch = client.channels.cache.get(String(body[key])); - if (!ch?.isTextBased?.()) { - return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` }); - } - setSetting(key, String(body[key])); + // Kanäle: müssen existierende Textkanäle sein (release darf leer sein = deaktiviert) + for (const key of ['commit_channel_id', 'devlog_channel_id', 'release_channel_id']) { + if (body[key] === undefined) continue; + const value = String(body[key]); + if (value === '' && key === 'release_channel_id') { + setSetting(key, ''); + continue; } + const ch = client.channels.cache.get(value); + if (!ch?.isTextBased?.()) { + return reply.code(400).send({ error: `${key}: Kanal nicht gefunden` }); + } + setSetting(key, value); } if (body.commit_feed_enabled !== undefined) { setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0'); @@ -168,7 +180,11 @@ ${rssItems} if (requireAdmin(request, reply)) return; const target = request.params.target; - const channelId = target === 'commit' ? commitChannelId() : target === 'devlog' ? devlogChannelId() : null; + const channelId = + target === 'commit' ? commitChannelId() + : target === 'devlog' ? devlogChannelId() + : target === 'release' ? releaseChannelId() + : null; if (!channelId) { return reply.code(400).send({ error: 'Kein Kanal konfiguriert' }); } @@ -180,7 +196,7 @@ ${rssItems} .setColor(0xf5c518) .setTitle('🔧 Test') .setDescription( - `Test-Nachricht für den **${target === 'commit' ? 'Commit' : 'Devlog'}-Kanal** — von der Settings-Seite ausgelöst.` + `Test-Nachricht für den **${{ commit: 'Commit', devlog: 'Devlog', release: 'Release' }[target]}-Kanal** — von der Settings-Seite ausgelöst.` ) .setFooter({ text: 'D4RKST3R // SETUP' }), ], diff --git a/src/web/server.js b/src/web/server.js index 7c291cb..0d95f04 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -8,9 +8,10 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { config } from '../config.js'; -import { saveCommits } from '../db.js'; +import { saveCommits, saveRelease } from '../db.js'; import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js'; import { postPushEmbed } from '../bot/commit-feed.js'; +import { postReleaseEmbed } from '../bot/release-feed.js'; import { registerAuthRoutes } from './auth.js'; import { registerApiRoutes } from './api.js'; import { registerDevlogEndpoint } from './devlog-endpoint.js'; @@ -103,8 +104,43 @@ export async function startWebServer(client) { return reply.code(401).send({ error: 'invalid signature' }); } - // Andere Events (Tag, Issue, …) freundlich ignorieren const event = request.headers['x-gitea-event']; + + // Release-Events → Ankündigung + Changelog-Archiv + if (event === 'release') { + const payload = request.body; + const repo = payload.repository?.full_name ?? 'unbekannt'; + if (repoIgnored(repo) || !payload.release) { + return { ok: true, ignored: `release ${repo}` }; + } + const release = { + repo, + tag: payload.release.tag_name ?? 'unbekannt', + name: payload.release.name ?? null, + body: payload.release.body ?? null, + url: payload.release.html_url ?? null, + prerelease: payload.release.prerelease ? 1 : 0, + published_at: payload.release.published_at ?? new Date().toISOString(), + }; + const isNew = saveRelease(release); + request.log.info(`Release ${release.tag} in ${repo} (${payload.action}, neu: ${isNew})`); + + // Nur neue Releases ankündigen — Updates/Löschungen nur still archivieren + let posted = false; + if (isNew && payload.action === 'published') { + try { + posted = await postReleaseEmbed(client, { + ...release, + prerelease: Boolean(release.prerelease), + }); + } catch (error) { + request.log.error({ err: error }, 'Release-Embed konnte nicht gepostet werden'); + } + } + return { ok: true, release: release.tag, posted }; + } + + // Andere Events (Tag, Issue, …) freundlich ignorieren if (event !== 'push') { return { ok: true, ignored: event }; }