port monkey changelog update [No GBP] (#19026)

* port monkey chnagelog update

* type safe

* .

* functions

* whyi s that even there
This commit is contained in:
Kashargul
2026-01-17 08:07:28 -08:00
committed by GitHub
parent d30a903ced
commit 7079d13f4d
11 changed files with 521 additions and 343 deletions
+64 -5
View File
@@ -1,13 +1,21 @@
/datum/changelog
var/static/list/changelog_items = list()
var/static/list/dates
var/static/list/testmerges
/datum/changelog/tgui_state()
return GLOB.tgui_always_state
/datum/changelog/tgui_interact(mob/user, datum/tgui/ui)
if(isnull(dates))
dates = get_dates()
if(isnull(testmerges))
testmerges = get_testmerge_data()
ui = SStgui.try_update_ui(user, src, ui)
if (!ui)
ui = new(user, src, "Changelog")
ui.set_autoupdate(FALSE)
ui.open()
/datum/changelog/tgui_act(action, list/params, datum/tgui/ui, datum/tgui_state/state)
@@ -22,11 +30,62 @@
return ui.send_asset(changelog_item)
/datum/changelog/tgui_static_data()
var/list/data = list( "dates" = list() )
var/regex/ymlRegex = regex(@"\.yml", "g")
var/list/data = list()
for(var/archive_file in sortList(flist("html/changelogs/archive/")))
var/archive_date = ymlRegex.Replace(archive_file, "")
data["dates"] = list(archive_date) + data["dates"]
data["dates"] = dates
data["testmerges"] = testmerges
data["discord_url"] = CONFIG_GET(string/discordurl)
return data
/datum/changelog/proc/get_dates()
. = list()
var/regex/yml_regex = regex(@"\.yml", "g")
for(var/archive_file in flist("html/changelogs/archive/"))
. += replacetext(archive_file, yml_regex, "")
/datum/changelog/proc/get_testmerge_data()
. = list()
for(var/datum/tgs_revision_information/test_merge/testmerge in world.TgsTestMerges())
if(!testmerge.body || findtext(testmerge.title, @"[s]"))
continue
var/list/changes = parse_github_changelog(testmerge.body)
if(!length(changes))
changes = list("unknown" = list("Changes are not documented. Ask the author ([testmerge.author]) to add a changelog to their PR!"))
var/list/testmerge_data = list(
"title" = trimtext("[testmerge.title]"),
"number" = testmerge.number,
"author" = testmerge.author,
"link" = testmerge.url,
"changes" = changes,
)
. += list(testmerge_data)
/proc/parse_github_changelog(body) as /list
// these shouldn't be static, as this will only be called a few times at most in get_testmerge_data, which will only be called once.
var/regex/cl_pattern = new(@"(:cl:|🆑)([\S \t]*)$")
var/regex/entry_pattern = new(@"(\w+): (.+)")
var/regex/end_pattern = new(@"^/(:cl:|🆑)")
var/regex/newline_pattern = new(@"(\r\n|\r|\n)")
// no changes, this is just using the default template
if(findtext_char(body, "add: Added new mechanics or gameplay changes"))
return
var/started = FALSE
var/list/lines = splittext_char(trimtext(body), newline_pattern)
for(var/line in lines)
line = trimtext(line)
if(findtext_char(line, end_pattern))
break
if(started)
if (findtext_char(line, entry_pattern))
var/change_type = trimtext(entry_pattern.group[1])
var/change_desc = trimtext(entry_pattern.group[2])
if(!change_type || !change_desc)
continue
LAZYADDASSOCLIST(., change_type, change_desc)
else
if(findtext_char(line, cl_pattern))
started = TRUE
-338
View File
@@ -1,338 +0,0 @@
import dateformat from 'dateformat';
import yaml from 'js-yaml';
import { Component, Fragment } from 'react';
import { resolveAsset } from 'tgui/assets';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import {
Box,
Button,
Dropdown,
Icon,
Section,
Stack,
Table,
} from 'tgui-core/components';
import { classes } from 'tgui-core/react';
import { sendAct as act } from '../events/act';
const icons = {
add: { icon: 'check-circle', color: 'green' },
admin: { icon: 'user-shield', color: 'purple' },
balance: { icon: 'balance-scale-right', color: 'yellow' },
bugfix: { icon: 'bug', color: 'green' },
code_imp: { icon: 'code', color: 'green' },
config: { icon: 'cogs', color: 'purple' },
expansion: { icon: 'check-circle', color: 'green' },
experiment: { icon: 'radiation', color: 'yellow' },
image: { icon: 'image', color: 'green' },
imageadd: { icon: 'tg-image-plus', color: 'green' },
imagedel: { icon: 'tg-image-minus', color: 'red' },
qol: { icon: 'hand-holding-heart', color: 'green' },
refactor: { icon: 'tools', color: 'green' },
rscadd: { icon: 'check-circle', color: 'green' },
rscdel: { icon: 'times-circle', color: 'red' },
server: { icon: 'server', color: 'purple' },
sound: { icon: 'volume-high', color: 'green' },
soundadd: { icon: 'tg-sound-plus', color: 'green' },
sounddel: { icon: 'tg-sound-minus', color: 'red' },
spellcheck: { icon: 'spell-check', color: 'green' },
tgs: { icon: 'toolbox', color: 'purple' },
tweak: { icon: 'wrench', color: 'green' },
unknown: { icon: 'info-circle', color: 'label' },
wip: { icon: 'hammer', color: 'orange' },
};
type Data = { dates: string[] };
type ChangelogProps = Record<never, never>;
interface ChangelogState {
data: string | { date: string; authors: { name: string; changes: string[] } };
selectedDate: string;
selectedIndex: number;
}
export class Changelog extends Component<ChangelogProps, ChangelogState> {
dateChoices: string[];
constructor(props) {
super(props);
this.state = {
data: 'Loading changelog data...',
selectedDate: '',
selectedIndex: 0,
};
this.dateChoices = [];
}
setData(data) {
this.setState({ data });
}
setSelectedDate(selectedDate) {
this.setState({ selectedDate });
}
setSelectedIndex(selectedIndex) {
this.setState({ selectedIndex });
}
getData = (date, attemptNumber = 1) => {
const self = this;
const maxAttempts = 6;
if (attemptNumber > maxAttempts) {
return this.setData(`Failed to load data after ${maxAttempts} attempts`);
}
act('get_month', { date });
fetch(resolveAsset(`${date}.yml`)).then(async (changelogData) => {
const result = await changelogData.text();
const errorRegex = /^Cannot find/;
if (errorRegex.test(result)) {
const timeout = 50 + attemptNumber * 50;
self.setData(`Loading changelog data${'.'.repeat(attemptNumber + 3)}`);
setTimeout(() => {
self.getData(date, attemptNumber + 1);
}, timeout);
} else {
self.setData(yaml.load(result, { schema: yaml.CORE_SCHEMA }));
}
});
};
componentDidMount() {
const {
data: { dates = [] },
} = useBackend<Data>();
if (dates) {
dates.forEach((date) => {
this.dateChoices.push(dateformat(date, 'mmmm yyyy', true));
});
this.setSelectedDate(this.dateChoices[0]);
this.getData(dates[0]);
}
}
render() {
const { data, selectedDate, selectedIndex } = this.state;
const {
data: { dates },
} = useBackend<Data>();
const { dateChoices } = this;
const dateDropdown = dateChoices.length > 0 && (
<Stack mb={1}>
<Stack.Item>
<Button
className="Changelog__Button"
disabled={selectedIndex === 0}
icon={'chevron-left'}
onClick={() => {
const index = selectedIndex - 1;
this.setData('Loading changelog data...');
this.setSelectedIndex(index);
this.setSelectedDate(dateChoices[index]);
window.scrollTo(
0,
document.body.scrollHeight ||
document.documentElement.scrollHeight,
);
return this.getData(dates[index]);
}}
/>
</Stack.Item>
<Stack.Item>
<Dropdown
displayText={selectedDate}
options={dateChoices}
onSelected={(value) => {
const index = dateChoices.indexOf(value);
this.setData('Loading changelog data...');
this.setSelectedIndex(index);
this.setSelectedDate(value);
window.scrollTo(
0,
document.body.scrollHeight ||
document.documentElement.scrollHeight,
);
return this.getData(dates[index]);
}}
selected={selectedDate}
width={'150px'}
/>
</Stack.Item>
<Stack.Item>
<Button
className="Changelog__Button"
disabled={selectedIndex === dateChoices.length - 1}
icon={'chevron-right'}
onClick={() => {
const index = selectedIndex + 1;
this.setData('Loading changelog data...');
this.setSelectedIndex(index);
this.setSelectedDate(dateChoices[index]);
window.scrollTo(
0,
document.body.scrollHeight ||
document.documentElement.scrollHeight,
);
return this.getData(dates[index]);
}}
/>
</Stack.Item>
</Stack>
);
const header = (
<Section>
<h1>VOREStation Changelist</h1>
<p>
{'The GitHub repository can be found '}
<a href="https://github.com/VOREStation/VOREStation">here</a>
{', recent GitHub contributors can be found '}
<a href="https://github.com/VOREStation/VOREStation/pulse/monthly">
here
</a>
.
</p>
<p>
{'Visit our wiki '}
<a href="https://wiki.vore-station.net/Main_Page">here</a>
{', check out our discord server '}
<a href="https://discord.gg/Zd5WMuq">here</a>.
</p>
{dateDropdown}
</Section>
);
const footer = (
<Section>
{dateDropdown}
<h3>VOREStation License</h3>
<p>
{'All code after '}
<a
href={
'https://github.com/VOREStation/VOREStation/commit/' +
'333c566b88108de218d882840e61928a9b759d8f'
}
>
commit 333c566b88108de218d882840e61928a9b759d8f on 2014/31/12 at
4:38 PM PST
</a>
{' is licensed under '}
<a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL v3</a>
{'. All code before that commit is licensed under '}
<a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU GPL v3</a>
{', including tools unless their readme specifies otherwise. See '}
<a href="https://github.com/VOREStation/VOREStation/blob/master/LICENSE">
LICENSE
</a>
{' and '}
<a href="https://github.com/VOREStation/VOREStation/blob/master/LICENSE-GPL3.txt">
GPLv3.txt
</a>
{' for more details.'}
</p>
<p>
The TGS DMAPI API is licensed as a subproject under the MIT license.
{' See the footer of '}
<a
href={
'https://github.com/VOREStation/VOREStation/blob/master' +
'/code/__DEFINES/tgs.dm'
}
>
code/__DEFINES/tgs.dm
</a>
{' and '}
<a
href={
'https://github.com/VOREStation/VOREStation/blob/master' +
'/code/modules/tgs/LICENSE'
}
>
code/modules/tgs/LICENSE
</a>
{' for the MIT license.'}
</p>
<p>
{'All assets including icons and sound are under a '}
<a href="https://creativecommons.org/licenses/by-sa/3.0/">
Creative Commons 3.0 BY-SA license
</a>
{' unless otherwise indicated.'}
</p>
</Section>
);
const changes =
typeof data === 'object' &&
Object.keys(data).length > 0 &&
Object.entries(data)
.reverse()
.map(([date, authors]) => (
<Section key={date} title={dateformat(date, 'd mmmm yyyy', true)}>
<Box ml={3}>
{Object.entries(authors).map(([name, changes]) => (
<Fragment key={name}>
<h4>{name} changed:</h4>
<Box ml={3}>
<Table>
{(changes as string[]).map((change) => {
const changeType = Object.keys(change)[0];
return (
<Table.Row key={changeType + change[changeType]}>
<Table.Cell
className={classes([
'Changelog__Cell',
'Changelog__Cell--Icon',
])}
>
<Icon
color={
icons[changeType]
? icons[changeType].color
: icons.unknown.icon
}
name={
icons[changeType]
? icons[changeType].icon
: icons.unknown.icon
}
/>
</Table.Cell>
<Table.Cell className="Changelog__Cell">
{change[changeType]}
</Table.Cell>
</Table.Row>
);
})}
</Table>
</Box>
</Fragment>
))}
</Box>
</Section>
));
return (
<Window title="Changelog" width={675} height={650}>
<Window.Content scrollable>
{header}
{changes}
{typeof data === 'string' && <p>{data}</p>}
{footer}
</Window.Content>
</Window>
);
}
}
@@ -0,0 +1,21 @@
import { Icon, Table } from 'tgui-core/components';
import { classes } from 'tgui-core/react';
import { icons } from './constatnts';
export const ChangeRow = (props: { kind: string; content: string }) => {
return (
<Table.Row>
<Table.Cell
className={classes(['Changelog__Cell', 'Changelog__Cell--Icon'])}
>
<Icon
color={
icons[props.kind] ? icons[props.kind].color : icons.unknown.color
}
name={icons[props.kind] ? icons[props.kind].icon : icons.unknown.icon}
/>
</Table.Cell>
<Table.Cell className="Changelog__Cell">{props.content}</Table.Cell>
</Table.Row>
);
};
@@ -0,0 +1,44 @@
import dateformat from 'dateformat';
import { Fragment } from 'react';
import { Box, Section, Table } from 'tgui-core/components';
import { ChangeRow } from './ChangeRow';
import type { ChangelogEntry } from './types';
export const Changes = (props: { data: ChangelogEntry | string }) => {
const { data } = props;
if (typeof data !== 'object' || Object.keys(data).length === 0) {
return null;
}
return (
<>
{Object.entries(data)
.reverse()
.map(([date, authors]) => (
<Section key={date} title={dateformat(date, 'd mmmm yyyy', true)}>
<Box ml={3}>
{Object.entries(authors).map(([name, changes]) => (
<Fragment key={name}>
<h4>{name} changed:</h4>
<Box ml={3}>
<Table>
{changes.map((change) =>
Object.entries(change).map(([changeType, content]) => (
<ChangeRow
key={changeType + content}
kind={changeType}
content={content}
/>
)),
)}
</Table>
</Box>
</Fragment>
))}
</Box>
</Section>
))}
</>
);
};
@@ -0,0 +1,77 @@
import { Button, Dropdown, Stack } from 'tgui-core/components';
export const DateDropdown = (props: {
selectedIndex: number;
setData: React.Dispatch<React.SetStateAction<string>>;
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>;
selectedDate: string;
setSelectedDate: React.Dispatch<React.SetStateAction<string>>;
dateChoices: string[];
sortedDates: string[];
getData: (date: string, attemptNumber?: number) => any;
}) => {
const {
selectedIndex,
setData,
setSelectedIndex,
selectedDate,
setSelectedDate,
dateChoices,
sortedDates,
getData,
} = props;
if (!dateChoices.length) {
return null;
}
function handleSelect(date: string) {
const index = dateChoices.indexOf(date);
if (index === -1) return;
setData('Loading changelog data...');
setSelectedIndex(index);
setSelectedDate(dateChoices[index]);
getData(sortedDates[index]);
}
function handlePrev() {
if (selectedIndex >= dateChoices.length - 1) return;
handleSelect(dateChoices[selectedIndex + 1]);
}
const handleNext = () => {
if (selectedIndex <= 0) return;
handleSelect(dateChoices[selectedIndex - 1]);
};
return (
<Stack mb={1}>
<Stack.Item>
<Button
className="Changelog__Button"
disabled={selectedIndex === dateChoices.length - 1}
icon="chevron-left"
onClick={handlePrev}
/>
</Stack.Item>
<Stack.Item>
<Dropdown
displayText={selectedDate}
options={dateChoices}
onSelected={handleSelect}
selected={selectedDate}
width="150px"
/>
</Stack.Item>
<Stack.Item>
<Button
className="Changelog__Button"
disabled={selectedIndex === 0}
icon="chevron-right"
onClick={handleNext}
/>
</Stack.Item>
</Stack>
);
};
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { Section } from 'tgui-core/components';
export const Footer = (props: { dateDropdown: ReactNode | null }) => {
const { dateDropdown } = props;
return (
<Section>
{dateDropdown}
<h3>VOREStation License</h3>
<p>
{'All code after '}
<a
href={
'https://github.com/VOREStation/VOREStation/commit/' +
'333c566b88108de218d882840e61928a9b759d8f'
}
>
commit 333c566b88108de218d882840e61928a9b759d8f on 2014/31/12 at 4:38
PM PST
</a>
{' is licensed under '}
<a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL v3</a>
{'. All code before that commit is licensed under '}
<a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU GPL v3</a>
{', including tools unless their readme specifies otherwise. See '}
<a href="https://github.com/VOREStation/VOREStation/blob/master/LICENSE">
LICENSE
</a>
{' and '}
<a href="https://github.com/VOREStation/VOREStation/blob/master/LICENSE-GPL3.txt">
GPLv3.txt
</a>
{' for more details.'}
</p>
<p>
The TGS DMAPI API is licensed as a subproject under the MIT license.
{' See the footer of '}
<a
href={
'https://github.com/VOREStation/VOREStation/blob/master' +
'/code/__DEFINES/tgs.dm'
}
>
code/__DEFINES/tgs.dm
</a>
{' and '}
<a
href={
'https://github.com/VOREStation/VOREStation/blob/master' +
'/code/modules/tgs/LICENSE'
}
>
code/modules/tgs/LICENSE
</a>
{' for the MIT license.'}
</p>
<p>
{'All assets including icons and sound are under a '}
<a href="https://creativecommons.org/licenses/by-sa/3.0/">
Creative Commons 3.0 BY-SA license
</a>
{' unless otherwise indicated.'}
</p>
</Section>
);
};
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { Section } from 'tgui-core/components';
export const Header = (props: { dateDropdown: ReactNode | null }) => {
const { dateDropdown } = props;
return (
<Section>
<h1>VOREStation Changelist</h1>
<p>
{'The GitHub repository can be found '}
<a href="https://github.com/VOREStation/VOREStation">here</a>
{', recent GitHub contributors can be found '}
<a href="https://github.com/VOREStation/VOREStation/pulse/monthly">
here
</a>
.
</p>
<p>
{'Visit our wiki '}
<a href="https://wiki.vore-station.net/Main_Page">here</a>
{', check out our discord server '}
<a href="https://discord.gg/Zd5WMuq">here</a>.
</p>
{dateDropdown}
</Section>
);
};
@@ -0,0 +1,58 @@
import { useBackend } from 'tgui/backend';
import { Box, Collapsible, Section, Stack, Table } from 'tgui-core/components';
import { ChangeRow } from './ChangeRow';
import type { ChangelogData } from './types';
export const Testmerges = (_props) => {
const {
data: { testmerges },
} = useBackend<ChangelogData>();
if (testmerges.length < 1) {
return null;
}
return (
<>
<Section px={1}>
<h4>
These are features being actively tested and developed on the server.
Please report any issues or feedback to the original PR, or on the
feedback thread on the Discord if there is one.
</h4>
</Section>
<Stack vertical>
{testmerges.map((testmerge) => {
const title = (
<a href={testmerge.link}>
#{testmerge.number}: &quot;{testmerge.title}&quot; by{' '}
{testmerge.author}
</a>
);
return (
<Stack.Item key={testmerge.number}>
<Section title={title}>
<Collapsible color="transparent" title="Changelog" open>
<Box ml={3}>
<Table>
{Object.entries(testmerge.changes).map(
([kind, changes]) =>
changes.map((desc) => (
<ChangeRow
key={kind + desc}
kind={kind}
content={desc}
/>
)),
)}
</Table>
</Box>
</Collapsible>
</Section>
</Stack.Item>
);
})}
</Stack>
</>
);
};
@@ -0,0 +1,27 @@
export const icons = {
bugfix: { icon: 'bug', color: 'green' },
wip: { icon: 'hammer', color: 'orange' },
qol: { icon: 'hand-holding-heart', color: 'green' },
sound: { icon: 'volume-high', color: 'green' },
soundadd: { icon: 'tg-sound-plus', color: 'green' },
sounddel: { icon: 'tg-sound-minus', color: 'red' },
add: { icon: 'check-circle', color: 'green' },
expansion: { icon: 'check-circle', color: 'green' },
rscadd: { icon: 'check-circle', color: 'green' },
rscdel: { icon: 'times-circle', color: 'red' },
image: { icon: 'image', color: 'green' },
imageadd: { icon: 'tg-image-plus', color: 'green' },
imagedel: { icon: 'tg-image-minus', color: 'red' },
spellcheck: { icon: 'spell-check', color: 'green' },
map: { icon: 'map', color: 'green' },
experiment: { icon: 'radiation', color: 'yellow' },
balance: { icon: 'balance-scale-right', color: 'yellow' },
code_imp: { icon: 'code', color: 'green' },
refactor: { icon: 'tools', color: 'green' },
config: { icon: 'cogs', color: 'purple' },
admin: { icon: 'user-shield', color: 'purple' },
server: { icon: 'server', color: 'purple' },
tgs: { icon: 'toolbox', color: 'purple' },
tweak: { icon: 'wrench', color: 'green' },
unknown: { icon: 'info-circle', color: 'label' },
};
@@ -0,0 +1,119 @@
import dateformat from 'dateformat';
import yaml from 'js-yaml';
import { useEffect, useMemo, useState } from 'react';
import { resolveAsset } from 'tgui/assets';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import { Stack } from 'tgui-core/components';
import { Changes } from './Changes';
import { DateDropdown } from './DateDropdown';
import { Footer } from './Resources/Footer';
import { Header } from './Resources/Header';
import { Testmerges } from './Testmerges';
import type { ChangelogData, ChangelogEntry } from './types';
export const Changelog = (props) => {
const {
act,
data: { dates, testmerges },
} = useBackend<ChangelogData>();
const [selectedDate, setSelectedDate] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [data, setData] = useState<string | ChangelogEntry>(
'Loading changelog data...',
);
const sortedDates = useMemo(() => dates.toSorted().toReversed(), [dates]);
const dateChoices: string[] = useMemo(
() => sortedDates.map((date) => dateformat(date, 'mmmm yyyy', true)),
[sortedDates],
);
function getData(date: string, attemptNumber = 1) {
const maxAttempts = 6;
if (attemptNumber > maxAttempts) {
return setData(`Failed to load data after ${maxAttempts} attempts`);
}
act('get_month', { date });
fetch(resolveAsset(`${date}.yml`)).then(async (changelogData) => {
const result = await changelogData.text();
const errorRegex = /^Cannot find/;
if (errorRegex.test(result)) {
const timeout = 50 + attemptNumber * 50;
setData(`Loading changelog data${'.'.repeat(attemptNumber + 3)}`);
setTimeout(() => {
getData(date, attemptNumber + 1);
}, timeout);
} else {
const parsed = yaml.load(result, { schema: yaml.CORE_SCHEMA });
if (parsed === null || parsed === undefined) {
setData('Changelog is empty or invalid');
} else if (typeof parsed === 'string' || typeof parsed === 'object') {
setData(parsed);
} else {
setData('Unexpected changelog format');
}
}
});
}
useEffect(() => {
setSelectedDate(dateChoices[0]);
getData(sortedDates[0]);
}, []);
return (
<Window
title="Changelog"
width={testmerges.length > 0 ? 1000 : 675}
height={650}
>
<Window.Content scrollable>
<Header
dateDropdown={
<DateDropdown
selectedIndex={selectedIndex}
setData={setData}
setSelectedIndex={setSelectedIndex}
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
dateChoices={dateChoices}
sortedDates={sortedDates}
getData={getData}
/>
}
/>
<Stack>
<Stack.Item grow>
<Changes data={data} />
</Stack.Item>
{testmerges.length > 0 && (
<Stack.Item width="50%">
<Testmerges />
</Stack.Item>
)}
</Stack>
{typeof data === 'string' && <p>{data}</p>}
<Footer
dateDropdown={
<DateDropdown
selectedIndex={selectedIndex}
setData={setData}
setSelectedIndex={setSelectedIndex}
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
dateChoices={dateChoices}
sortedDates={sortedDates}
getData={getData}
/>
}
/>
</Window.Content>
</Window>
);
};
@@ -0,0 +1,16 @@
export type ChangelogEntry = Record<string, Record<string, ChangeEntry[]>>;
type ChangeEntry = Record<string, string>;
export type Testmerge = {
title: string;
number: number;
link: string;
author: string;
changes: Record<string, string[]>;
};
export type ChangelogData = {
discord_url?: string;
dates: string[];
testmerges: Testmerge[];
};