Smart changelog (#5205)
Added a smart Changelog : - Publish the Changelog before the app release. If the release has not yet been pushed to production, do not display it. - When the app release is done, make the Changelog available with the correct date. - If the Changelog writing is delayed because the release has already been made, publish it immediately. - Display everything locally to be able to iterate on the changelog and have a preview Added an endpoint for the Changelog --------- Co-authored-by: Ady Beraud <a.beraud96@gmail.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -63,8 +63,10 @@ const gabarito = Gabarito({
|
|||||||
export const Release = ({
|
export const Release = ({
|
||||||
release,
|
release,
|
||||||
mdxReleaseContent,
|
mdxReleaseContent,
|
||||||
|
githubPublishedAt,
|
||||||
}: {
|
}: {
|
||||||
release: ReleaseNote;
|
release: ReleaseNote;
|
||||||
|
githubPublishedAt: string;
|
||||||
mdxReleaseContent: ReactElement<any, string | JSXElementConstructor<any>>;
|
mdxReleaseContent: ReactElement<any, string | JSXElementConstructor<any>>;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@ -73,9 +75,9 @@ export const Release = ({
|
|||||||
<StyledVersion>
|
<StyledVersion>
|
||||||
<StyledRelease>{release.release}</StyledRelease>
|
<StyledRelease>{release.release}</StyledRelease>
|
||||||
<StyledDate>
|
<StyledDate>
|
||||||
{release.date.endsWith(new Date().getFullYear().toString())
|
{githubPublishedAt.endsWith(new Date().getFullYear().toString())
|
||||||
? release.date.slice(0, -5)
|
? githubPublishedAt.slice(0, -5)
|
||||||
: release.date}
|
: githubPublishedAt}
|
||||||
</StyledDate>
|
</StyledDate>
|
||||||
</StyledVersion>
|
</StyledVersion>
|
||||||
<ArticleContent>{mdxReleaseContent}</ArticleContent>
|
<ArticleContent>{mdxReleaseContent}</ArticleContent>
|
||||||
|
|||||||
41
packages/twenty-website/src/app/api/releases/route.tsx
Normal file
41
packages/twenty-website/src/app/api/releases/route.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { getGithubReleaseDateFromReleaseNote } from '@/app/releases/utils/get-github-release-date-from-release-note';
|
||||||
|
import { getReleases } from '@/app/releases/utils/get-releases';
|
||||||
|
import { getVisibleReleases } from '@/app/releases/utils/get-visible-releases';
|
||||||
|
import { findAll } from '@/database/database';
|
||||||
|
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const githubReleases = (await findAll(
|
||||||
|
githubReleasesModel,
|
||||||
|
desc(githubReleasesModel.publishedAt),
|
||||||
|
)) as GithubReleases[];
|
||||||
|
|
||||||
|
const latestGithubRelease = githubReleases[0];
|
||||||
|
const releaseNotes = await getReleases();
|
||||||
|
|
||||||
|
const visibleReleasesNotes = getVisibleReleases(
|
||||||
|
releaseNotes,
|
||||||
|
latestGithubRelease.tagName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedReleasesNotes = visibleReleasesNotes.map((releaseNote) => ({
|
||||||
|
...releaseNote,
|
||||||
|
publishedAt: getGithubReleaseDateFromReleaseNote(
|
||||||
|
githubReleases,
|
||||||
|
releaseNote.release,
|
||||||
|
releaseNote.date,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Response.json(formattedReleasesNotes);
|
||||||
|
} catch (error: any) {
|
||||||
|
return new Response(`Github releases error: ${error?.message}`, {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getReleases } from '@/app/releases/get-releases';
|
import { getReleases } from '@/app/releases/utils/get-releases';
|
||||||
|
|
||||||
export interface ReleaseNote {
|
export interface ReleaseNote {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
import { Line } from '@/app/_components/releases/Line';
|
import { Line } from '@/app/_components/releases/Line';
|
||||||
import { Release } from '@/app/_components/releases/Release';
|
import { Release } from '@/app/_components/releases/Release';
|
||||||
import { Title } from '@/app/_components/releases/StyledTitle';
|
import { Title } from '@/app/_components/releases/StyledTitle';
|
||||||
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
|
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
|
||||||
|
import { getGithubReleaseDateFromReleaseNote } from '@/app/releases/utils/get-github-release-date-from-release-note';
|
||||||
import {
|
import {
|
||||||
getMdxReleasesContent,
|
getMdxReleasesContent,
|
||||||
getReleases,
|
getReleases,
|
||||||
} from '@/app/releases/get-releases';
|
} from '@/app/releases/utils/get-releases';
|
||||||
|
import { getVisibleReleases } from '@/app/releases/utils/get-visible-releases';
|
||||||
|
import { findAll } from '@/database/database';
|
||||||
|
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
||||||
|
import { pgGithubReleasesModel } from '@/database/schema-postgres';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Twenty - Releases',
|
title: 'Twenty - Releases',
|
||||||
@ -19,20 +25,37 @@ export const metadata: Metadata = {
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const Home = async () => {
|
const Home = async () => {
|
||||||
const releases = await getReleases();
|
const githubReleases = (await findAll(
|
||||||
const mdxReleasesContent = await getMdxReleasesContent(releases);
|
githubReleasesModel,
|
||||||
|
desc(pgGithubReleasesModel.publishedAt),
|
||||||
|
)) as GithubReleases[];
|
||||||
|
|
||||||
|
const latestGithubRelease = githubReleases[0];
|
||||||
|
const releaseNotes = await getReleases();
|
||||||
|
|
||||||
|
const visibleReleasesNotes = getVisibleReleases(
|
||||||
|
releaseNotes,
|
||||||
|
latestGithubRelease.tagName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mdxReleasesContent = await getMdxReleasesContent(releaseNotes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentContainer>
|
<ContentContainer>
|
||||||
<Title />
|
<Title />
|
||||||
|
|
||||||
{releases.map((note, index) => (
|
{visibleReleasesNotes.map((note, index) => (
|
||||||
<React.Fragment key={note.slug}>
|
<React.Fragment key={note.slug}>
|
||||||
<Release
|
<Release
|
||||||
|
githubPublishedAt={getGithubReleaseDateFromReleaseNote(
|
||||||
|
githubReleases,
|
||||||
|
note.release,
|
||||||
|
note.date,
|
||||||
|
)}
|
||||||
release={note}
|
release={note}
|
||||||
mdxReleaseContent={mdxReleasesContent[index]}
|
mdxReleaseContent={mdxReleasesContent[index]}
|
||||||
/>
|
/>
|
||||||
{index != releases.length - 1 && <Line />}
|
{index != releaseNotes.length - 1 && <Line />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
export const getFormattedReleaseNumber = (versionNumber: string) => {
|
||||||
|
const formattedVersion = versionNumber.replace('v', '');
|
||||||
|
|
||||||
|
const parts = formattedVersion.split('.').map(Number);
|
||||||
|
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Version must be in the format major.minor.patch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign weights. Adjust these based on your needs.
|
||||||
|
const majorWeight = 10000;
|
||||||
|
const minorWeight = 100;
|
||||||
|
const patchWeight = 1;
|
||||||
|
|
||||||
|
const numericVersion =
|
||||||
|
parts[0] * majorWeight + parts[1] * minorWeight + parts[2] * patchWeight;
|
||||||
|
|
||||||
|
return numericVersion;
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { GithubReleases } from '@/database/model';
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(date) + getOrdinal(date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrdinal(day: number) {
|
||||||
|
if (day > 3 && day < 21) return 'th';
|
||||||
|
switch (day % 10) {
|
||||||
|
case 1:
|
||||||
|
return 'st';
|
||||||
|
case 2:
|
||||||
|
return 'nd';
|
||||||
|
case 3:
|
||||||
|
return 'rd';
|
||||||
|
default:
|
||||||
|
return 'th';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGithubReleaseDateFromReleaseNote = (
|
||||||
|
githubReleases: GithubReleases[],
|
||||||
|
noteTagName: string,
|
||||||
|
noteDate: string,
|
||||||
|
) => {
|
||||||
|
const formattedNoteTagName = `v${noteTagName}`;
|
||||||
|
const date = githubReleases?.find?.(
|
||||||
|
(githubRelease) => githubRelease?.tagName === formattedNoteTagName,
|
||||||
|
)?.publishedAt;
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteDate;
|
||||||
|
};
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { ReleaseNote } from '@/app/releases/api/route';
|
||||||
|
import { getFormattedReleaseNumber } from '@/app/releases/utils/get-formatted-release-number';
|
||||||
|
|
||||||
|
export const getVisibleReleases = (
|
||||||
|
releaseNotes: ReleaseNote[],
|
||||||
|
publishedReleaseVersion: string,
|
||||||
|
) => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') return releaseNotes;
|
||||||
|
|
||||||
|
const publishedVersionNumber = getFormattedReleaseNumber(
|
||||||
|
publishedReleaseVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
return releaseNotes.filter(
|
||||||
|
(releaseNote) =>
|
||||||
|
getFormattedReleaseNumber(releaseNote.release) <= publishedVersionNumber,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -22,7 +22,10 @@ const findOne = (model: any, orderBy: any) => {
|
|||||||
return pgDb.select().from(model).orderBy(orderBy).limit(1).execute();
|
return pgDb.select().from(model).orderBy(orderBy).limit(1).execute();
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAll = (model: any) => {
|
const findAll = (model: any, orderBy?: any) => {
|
||||||
|
if (orderBy) {
|
||||||
|
return pgDb.select().from(model).orderBy(orderBy).execute();
|
||||||
|
}
|
||||||
return pgDb.select().from(model).execute();
|
return pgDb.select().from(model).execute();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
8
packages/twenty-website/src/database/init-database.ts
Normal file
8
packages/twenty-website/src/database/init-database.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { migrate } from '@/database/database';
|
||||||
|
|
||||||
|
export const initDatabase = async () => {
|
||||||
|
await migrate();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
initDatabase();
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "githubReleases" (
|
||||||
|
"tagName" text PRIMARY KEY NOT NULL,
|
||||||
|
"publishedAt" date NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "githubStars" (
|
||||||
|
"timestamp" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"numberOfStars" integer
|
||||||
|
);
|
||||||
@ -15,6 +15,13 @@
|
|||||||
"when": 1713792223113,
|
"when": 1713792223113,
|
||||||
"tag": "0001_marvelous_eddie_brock",
|
"tag": "0001_marvelous_eddie_brock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1714375499735,
|
||||||
|
"tag": "0002_demonic_matthew_murdock",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
pgGithubReleasesModel,
|
||||||
pgGithubStars,
|
pgGithubStars,
|
||||||
pgIssueLabels,
|
pgIssueLabels,
|
||||||
pgIssues,
|
pgIssues,
|
||||||
@ -16,6 +17,7 @@ export const pullRequestLabelModel = pgPullRequestLabels;
|
|||||||
export const issueLabelModel = pgIssueLabels;
|
export const issueLabelModel = pgIssueLabels;
|
||||||
|
|
||||||
export const githubStarsModel = pgGithubStars;
|
export const githubStarsModel = pgGithubStars;
|
||||||
|
export const githubReleasesModel = pgGithubReleasesModel;
|
||||||
|
|
||||||
export type User = typeof pgUsers.$inferSelect;
|
export type User = typeof pgUsers.$inferSelect;
|
||||||
export type PullRequest = typeof pgPullRequests.$inferSelect;
|
export type PullRequest = typeof pgPullRequests.$inferSelect;
|
||||||
@ -31,3 +33,4 @@ export type LabelInsert = typeof pgLabels.$inferInsert;
|
|||||||
export type PullRequestLabelInsert = typeof pgPullRequestLabels.$inferInsert;
|
export type PullRequestLabelInsert = typeof pgPullRequestLabels.$inferInsert;
|
||||||
export type IssueLabelInsert = typeof pgIssueLabels.$inferInsert;
|
export type IssueLabelInsert = typeof pgIssueLabels.$inferInsert;
|
||||||
export type GithubStars = typeof pgGithubStars.$inferInsert;
|
export type GithubStars = typeof pgGithubStars.$inferInsert;
|
||||||
|
export type GithubReleases = typeof pgGithubReleasesModel.$inferInsert;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
import { date, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export const pgUsers = pgTable('users', {
|
export const pgUsers = pgTable('users', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@ -55,3 +55,8 @@ export const pgGithubStars = pgTable('githubStars', {
|
|||||||
timestamp: timestamp('timestamp').notNull().defaultNow(),
|
timestamp: timestamp('timestamp').notNull().defaultNow(),
|
||||||
numberOfStars: integer('numberOfStars'),
|
numberOfStars: integer('numberOfStars'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const pgGithubReleasesModel = pgTable('githubReleases', {
|
||||||
|
tagName: text('tagName').primaryKey(),
|
||||||
|
publishedAt: date('publishedAt', { mode: 'string' }).notNull(),
|
||||||
|
});
|
||||||
|
|||||||
@ -68,12 +68,24 @@ export interface Stargazers {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Releases {
|
||||||
|
nodes: ReleaseNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseNode {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Repository {
|
export interface Repository {
|
||||||
repository: {
|
repository: {
|
||||||
pullRequests: PullRequests;
|
pullRequests: PullRequests;
|
||||||
issues: Issues;
|
issues: Issues;
|
||||||
assignableUsers: AssignableUsers;
|
assignableUsers: AssignableUsers;
|
||||||
stargazers: Stargazers;
|
stargazers: Stargazers;
|
||||||
|
releases: Releases;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { fetchIssuesPRs } from '@/github-sync/contributors/fetch-issues-prs';
|
|||||||
import { saveIssuesToDB } from '@/github-sync/contributors/save-issues-to-db';
|
import { saveIssuesToDB } from '@/github-sync/contributors/save-issues-to-db';
|
||||||
import { savePRsToDB } from '@/github-sync/contributors/save-prs-to-db';
|
import { savePRsToDB } from '@/github-sync/contributors/save-prs-to-db';
|
||||||
import { IssueNode, PullRequestNode } from '@/github-sync/contributors/types';
|
import { IssueNode, PullRequestNode } from '@/github-sync/contributors/types';
|
||||||
|
import { fetchAndSaveGithubReleases } from '@/github-sync/github-releases/fetch-and-save-github-releases';
|
||||||
import { fetchAndSaveGithubStars } from '@/github-sync/github-stars/fetch-and-save-github-stars';
|
import { fetchAndSaveGithubStars } from '@/github-sync/github-stars/fetch-and-save-github-stars';
|
||||||
|
|
||||||
export const fetchAndSaveGithubData = async () => {
|
export const fetchAndSaveGithubData = async () => {
|
||||||
@ -22,6 +23,7 @@ export const fetchAndSaveGithubData = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await fetchAndSaveGithubStars(query);
|
await fetchAndSaveGithubStars(query);
|
||||||
|
await fetchAndSaveGithubReleases(query);
|
||||||
|
|
||||||
const assignableUsers = await fetchAssignableUsers(query);
|
const assignableUsers = await fetchAssignableUsers(query);
|
||||||
const fetchedPRs = (await fetchIssuesPRs(
|
const fetchedPRs = (await fetchIssuesPRs(
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { graphql } from '@octokit/graphql';
|
||||||
|
|
||||||
|
import { insertMany } from '@/database/database';
|
||||||
|
import { githubReleasesModel } from '@/database/model';
|
||||||
|
import { Repository } from '@/github-sync/contributors/types';
|
||||||
|
|
||||||
|
export const fetchAndSaveGithubReleases = async (
|
||||||
|
query: typeof graphql,
|
||||||
|
): Promise<void> => {
|
||||||
|
const { repository } = await query<Repository>(`
|
||||||
|
query {
|
||||||
|
repository(owner: "twentyhq", name: "twenty") {
|
||||||
|
releases(first: 100) {
|
||||||
|
nodes {
|
||||||
|
tagName
|
||||||
|
publishedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await insertMany(githubReleasesModel, repository.releases.nodes, {
|
||||||
|
onConflictKey: 'tagName',
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user