Release-Ankündigungen + öffentliche Changelog-Seite

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:37:28 +02:00
co-authored by Claude Opus 4.8
parent 5a57108230
commit be1fc227d0
9 changed files with 273 additions and 15 deletions
+50
View File
@@ -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<boolean>} 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;
}
+41
View File
@@ -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 {
+5
View File
@@ -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';
+28 -12
View File
@@ -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' }),
],
+38 -2
View File
@@ -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 };
}