Improve website github sync (#4259)

This commit is contained in:
Félix Malfait
2024-03-01 15:15:55 +01:00
committed by GitHub
parent 4242b546b6
commit 59c4d114d6
15 changed files with 630 additions and 407 deletions

View File

@ -0,0 +1,21 @@
import { graphql } from '@octokit/graphql';
import { Repository } from '@/app/contributors/api/types';
export async function fetchAssignableUsers(
query: typeof graphql,
): Promise<Set<string>> {
const { repository } = await query<Repository>(`
query {
repository(owner: "twentyhq", name: "twenty") {
assignableUsers(first: 100) {
nodes {
login
}
}
}
}
`);
return new Set(repository.assignableUsers.nodes.map((user) => user.login));
}

View File

@ -0,0 +1,102 @@
import { graphql } from '@octokit/graphql';
import {
IssueNode,
PullRequestNode,
Repository,
} from '@/app/contributors/api/types';
export async function fetchIssuesPRs(
query: typeof graphql,
cursor: string | null = null,
isIssues: boolean = false,
accumulatedData: Array<PullRequestNode | IssueNode> = [],
): Promise<Array<PullRequestNode | IssueNode>> {
const { repository } = await query<Repository>(
`
query ($cursor: String) {
repository(owner: "twentyhq", name: "twenty") {
pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${isIssues}) {
nodes {
id
title
body
url
createdAt
updatedAt
closedAt
mergedAt
author {
resourcePath
login
avatarUrl(size: 460)
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${!isIssues}) {
nodes {
id
title
body
url
createdAt
updatedAt
closedAt
author {
resourcePath
login
avatarUrl
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{ cursor },
);
const newAccumulatedData: Array<PullRequestNode | IssueNode> = [
...accumulatedData,
...(isIssues ? repository.issues.nodes : repository.pullRequests.nodes),
];
const pageInfo = isIssues
? repository.issues.pageInfo
: repository.pullRequests.pageInfo;
if (pageInfo.hasNextPage) {
return fetchIssuesPRs(
query,
pageInfo.endCursor,
isIssues,
newAccumulatedData,
);
} else {
return newAccumulatedData;
}
}

View File

@ -1,335 +0,0 @@
export const dynamic = 'force-dynamic';
import { global } from '@apollo/client/utilities/globals';
import { graphql } from '@octokit/graphql';
import { insertMany, migrate } from '@/database/database';
import {
issueLabelModel,
issueModel,
labelModel,
pullRequestLabelModel,
pullRequestModel,
userModel,
} from '@/database/model';
interface LabelNode {
id: string;
name: string;
color: string;
description: string;
}
export interface AuthorNode {
resourcePath: string;
login: string;
avatarUrl: string;
url: string;
}
interface PullRequestNode {
id: string;
title: string;
body: string;
url: string;
createdAt: string;
updatedAt: string;
closedAt: string;
mergedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
interface IssueNode {
id: string;
title: string;
body: string;
url: string;
createdAt: string;
updatedAt: string;
closedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
interface PageInfo {
hasNextPage: boolean;
endCursor: string | null;
}
interface PullRequests {
nodes: PullRequestNode[];
pageInfo: PageInfo;
}
interface Issues {
nodes: IssueNode[];
pageInfo: PageInfo;
}
interface AssignableUserNode {
login: string;
}
interface AssignableUsers {
nodes: AssignableUserNode[];
}
interface RepoData {
repository: {
pullRequests: PullRequests;
issues: Issues;
assignableUsers: AssignableUsers;
};
}
const query = graphql.defaults({
headers: {
Authorization: 'bearer ' + global.process.env.GITHUB_TOKEN,
},
});
async function fetchData(
cursor: string | null = null,
isIssues: boolean = false,
accumulatedData: Array<PullRequestNode | IssueNode> = [],
): Promise<Array<PullRequestNode | IssueNode>> {
const { repository } = await query<RepoData>(
`
query ($cursor: String) {
repository(owner: "twentyhq", name: "twenty") {
pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${isIssues}) {
nodes {
id
title
body
url
createdAt
updatedAt
closedAt
mergedAt
author {
resourcePath
login
avatarUrl(size: 460)
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${!isIssues}) {
nodes {
id
title
body
url
createdAt
updatedAt
closedAt
author {
resourcePath
login
avatarUrl
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{ cursor },
);
const newAccumulatedData: Array<PullRequestNode | IssueNode> = [
...accumulatedData,
...(isIssues ? repository.issues.nodes : repository.pullRequests.nodes),
];
const pageInfo = isIssues
? repository.issues.pageInfo
: repository.pullRequests.pageInfo;
if (pageInfo.hasNextPage) {
return fetchData(pageInfo.endCursor, isIssues, newAccumulatedData);
} else {
return newAccumulatedData;
}
}
async function fetchAssignableUsers(): Promise<Set<string>> {
const { repository } = await query<RepoData>(`
query {
repository(owner: "twentyhq", name: "twenty") {
assignableUsers(first: 100) {
nodes {
login
}
}
}
}
`);
return new Set(repository.assignableUsers.nodes.map((user) => user.login));
}
export async function GET() {
if (!global.process.env.GITHUB_TOKEN) {
return new Response('No GitHub token provided', { status: 500 });
}
await migrate();
// TODO if we ever hit API Rate Limiting
const lastPRCursor = null;
const lastIssueCursor = null;
const assignableUsers = await fetchAssignableUsers();
const fetchedPRs = (await fetchData(lastPRCursor)) as Array<PullRequestNode>;
const fetchedIssues = (await fetchData(
lastIssueCursor,
true,
)) as Array<IssueNode>;
for (const pr of fetchedPRs) {
if (pr.author == null) {
continue;
}
await insertMany(
userModel,
[
{
id: pr.author.login,
avatarUrl: pr.author.avatarUrl,
url: pr.author.url,
isEmployee: assignableUsers.has(pr.author.login) ? '1' : '0',
},
],
{ onConflictKey: 'id' },
);
await insertMany(
pullRequestModel,
[
{
id: pr.id,
title: pr.title,
body: pr.body,
url: pr.url,
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
closedAt: pr.closedAt,
mergedAt: pr.mergedAt,
authorId: pr.author.login,
},
],
{ onConflictKey: 'id' },
);
for (const label of pr.labels.nodes) {
await insertMany(
labelModel,
[
{
id: label.id,
name: label.name,
color: label.color,
description: label.description,
},
],
{ onConflictKey: 'id' },
);
await insertMany(pullRequestLabelModel, [
{
pullRequestId: pr.id,
labelId: label.id,
},
]);
}
}
for (const issue of fetchedIssues) {
if (issue.author == null) {
continue;
}
await insertMany(
userModel,
[
{
id: issue.author.login,
avatarUrl: issue.author.avatarUrl,
url: issue.author.url,
isEmployee: assignableUsers.has(issue.author.login) ? '1' : '0',
},
],
{ onConflictKey: 'id' },
);
await insertMany(
issueModel,
[
{
id: issue.id,
title: issue.title,
body: issue.body,
url: issue.url,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
closedAt: issue.closedAt,
authorId: issue.author.login,
},
],
{ onConflictKey: 'id' },
);
for (const label of issue.labels.nodes) {
await insertMany(
labelModel,
[
{
id: label.id,
name: label.name,
color: label.color,
description: label.description,
},
],
{ onConflictKey: 'id' },
);
await insertMany(issueLabelModel, [
{
pullRequestId: issue.id,
labelId: label.id,
},
]);
}
}
return new Response('Data synced', {
status: 200,
});
}

View File

@ -0,0 +1,46 @@
export const dynamic = 'force-dynamic';
import { global } from '@apollo/client/utilities/globals';
import { graphql } from '@octokit/graphql';
import { fetchAssignableUsers } from '@/app/contributors/api/fetch-assignable-users';
import { fetchIssuesPRs } from '@/app/contributors/api/fetch-issues-prs';
import { saveIssuesToDB } from '@/app/contributors/api/save-issues-to-db';
import { savePRsToDB } from '@/app/contributors/api/save-prs-to-db';
import { IssueNode, PullRequestNode } from '@/app/contributors/api/types';
import { migrate } from '@/database/database';
export async function GET() {
if (!global.process.env.GITHUB_TOKEN) {
return new Response('No GitHub token provided', { status: 500 });
}
const query = graphql.defaults({
headers: {
Authorization: 'bearer ' + global.process.env.GITHUB_TOKEN,
},
});
await migrate();
const assignableUsers = await fetchAssignableUsers(query);
const fetchedPRs = (await fetchIssuesPRs(
query,
null,
false,
[],
)) as Array<PullRequestNode>;
const fetchedIssues = (await fetchIssuesPRs(
query,
null,
true,
[],
)) as Array<IssueNode>;
savePRsToDB(fetchedPRs, assignableUsers);
saveIssuesToDB(fetchedIssues, assignableUsers);
return new Response('Data synced', {
status: 200,
});
}

View File

@ -0,0 +1,69 @@
import { IssueNode } from '@/app/contributors/api/types';
import { insertMany } from '@/database/database';
import {
issueLabelModel,
issueModel,
labelModel,
userModel,
} from '@/database/model';
export async function saveIssuesToDB(
issues: Array<IssueNode>,
assignableUsers: Set<string>,
) {
for (const issue of issues) {
if (issue.author == null) {
continue;
}
await insertMany(
userModel,
[
{
id: issue.author.login,
avatarUrl: issue.author.avatarUrl,
url: issue.author.url,
isEmployee: assignableUsers.has(issue.author.login) ? '1' : '0',
},
],
{ onConflictKey: 'id' },
);
await insertMany(
issueModel,
[
{
id: issue.id,
title: issue.title,
body: issue.body,
url: issue.url,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
closedAt: issue.closedAt,
authorId: issue.author.login,
},
],
{ onConflictKey: 'id' },
);
for (const label of issue.labels.nodes) {
await insertMany(
labelModel,
[
{
id: label.id,
name: label.name,
color: label.color,
description: label.description,
},
],
{ onConflictKey: 'id' },
);
await insertMany(issueLabelModel, [
{
pullRequestId: issue.id,
labelId: label.id,
},
]);
}
}
}

View File

@ -0,0 +1,70 @@
import { PullRequestNode } from '@/app/contributors/api/types';
import { insertMany } from '@/database/database';
import {
labelModel,
pullRequestLabelModel,
pullRequestModel,
userModel,
} from '@/database/model';
export async function savePRsToDB(
prs: Array<PullRequestNode>,
assignableUsers: Set<string>,
) {
for (const pr of prs) {
if (pr.author == null) {
continue;
}
await insertMany(
userModel,
[
{
id: pr.author.login,
avatarUrl: pr.author.avatarUrl,
url: pr.author.url,
isEmployee: assignableUsers.has(pr.author.login) ? '1' : '0',
},
],
{ onConflictKey: 'id' },
);
await insertMany(
pullRequestModel,
[
{
id: pr.id,
title: pr.title,
body: pr.body,
url: pr.url,
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
closedAt: pr.closedAt,
mergedAt: pr.mergedAt,
authorId: pr.author.login,
},
],
{ onConflictKey: 'id' },
);
for (const label of pr.labels.nodes) {
await insertMany(
labelModel,
[
{
id: label.id,
name: label.name,
color: label.color,
description: label.description,
},
],
{ onConflictKey: 'id' },
);
await insertMany(pullRequestLabelModel, [
{
pullRequestId: pr.id,
labelId: label.id,
},
]);
}
}
}

View File

@ -0,0 +1,99 @@
import { graphql } from '@octokit/graphql';
import {
IssueNode,
PullRequestNode,
SearchIssuesPRsQuery,
} from '@/app/contributors/api/types';
export async function searchIssuesPRs(
query: typeof graphql,
cursor: string | null = null,
isIssues: boolean = false,
accumulatedData: Array<PullRequestNode | IssueNode> = [],
): Promise<Array<PullRequestNode | IssueNode>> {
const { search } = await query<SearchIssuesPRsQuery>(
`
query searchPullRequestsAndIssues($cursor: String) {
search(query: "repo:twentyhq/twenty ${
isIssues ? 'is:issue' : 'is:pr'
} updated:>2024-02-27", type: ISSUE, first: 100, after: $cursor) {
edges {
node {
... on PullRequest {
id
title
body
url
createdAt
updatedAt
closedAt
mergedAt
author {
resourcePath
login
avatarUrl(size: 460)
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
... on Issue {
id
title
body
url
createdAt
updatedAt
closedAt
author {
resourcePath
login
avatarUrl
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`,
{
cursor,
},
);
const newAccumulatedData: Array<PullRequestNode | IssueNode> = [
...accumulatedData,
...search.edges.map(({ node }) => node),
];
const pageInfo = search.pageInfo;
if (pageInfo.hasNextPage) {
return searchIssuesPRs(
query,
pageInfo.endCursor,
isIssues,
newAccumulatedData,
);
} else {
return newAccumulatedData;
}
}

View File

@ -0,0 +1,86 @@
export interface LabelNode {
id: string;
name: string;
color: string;
description: string;
}
export interface AuthorNode {
resourcePath: string;
login: string;
avatarUrl: string;
url: string;
}
export interface PullRequestNode {
id: string;
title: string;
body: string;
url: string;
createdAt: string;
updatedAt: string;
closedAt: string;
mergedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
export interface IssueNode {
id: string;
title: string;
body: string;
url: string;
createdAt: string;
updatedAt: string;
closedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
export interface PageInfo {
hasNextPage: boolean;
endCursor: string | null;
}
export interface PullRequests {
nodes: PullRequestNode[];
pageInfo: PageInfo;
}
export interface Issues {
nodes: IssueNode[];
pageInfo: PageInfo;
}
export interface AssignableUserNode {
login: string;
}
export interface AssignableUsers {
nodes: AssignableUserNode[];
}
export interface Repository {
repository: {
pullRequests: PullRequests;
issues: Issues;
assignableUsers: AssignableUsers;
};
}
export interface SearchEdgeNode {
node: IssueNode | PullRequestNode;
}
export interface SearchEdges {
edges: SearchEdgeNode[];
pageInfo: PageInfo;
}
export interface SearchIssuesPRsQuery {
search: SearchEdges;
}

View File

@ -0,0 +1,55 @@
import { graphql } from '@octokit/graphql';
import { desc } from 'drizzle-orm';
import { fetchAssignableUsers } from '@/app/contributors/api/fetch-assignable-users';
import { saveIssuesToDB } from '@/app/contributors/api/save-issues-to-db';
import { savePRsToDB } from '@/app/contributors/api/save-prs-to-db';
import { searchIssuesPRs } from '@/app/contributors/api/search-issues-prs';
import { IssueNode, PullRequestNode } from '@/app/contributors/api/types';
import { findOne } from '@/database/database';
import { issueModel, pullRequestModel } from '@/database/model';
export async function GET() {
if (!global.process.env.GITHUB_TOKEN) {
return new Response('No GitHub token provided', { status: 500 });
}
const query = graphql.defaults({
headers: {
Authorization: 'bearer ' + global.process.env.GITHUB_TOKEN,
},
});
const assignableUsers = await fetchAssignableUsers(query);
const mostRecentPR = findOne(
pullRequestModel,
desc(pullRequestModel.updatedAt),
);
const mostRecentIssue = findOne(issueModel, desc(issueModel.updatedAt));
if (!mostRecentPR || !mostRecentIssue) {
return new Response('Run Init command first', { status: 400 });
}
const fetchedPRs = (await searchIssuesPRs(
query,
null,
false,
[],
)) as Array<PullRequestNode>;
const fetchedIssues = (await searchIssuesPRs(
query,
null,
true,
[],
)) as Array<IssueNode>;
savePRsToDB(fetchedPRs, assignableUsers);
saveIssuesToDB(fetchedIssues, assignableUsers);
return new Response('Data synced', {
status: 200,
});
}

View File

@ -1,9 +1,6 @@
import { compareSemanticVersions } from '@/shared-utils/compareSemanticVersions';
import fs from 'fs';
import matter from 'gray-matter';
import { NextRequest, NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server';
import { getReleases } from '@/app/releases/get-releases';
export interface ReleaseNote {
slug: string;
@ -12,56 +9,12 @@ export interface ReleaseNote {
content: string;
}
const BASE_URL = 'https://twenty.com/';
// WARNING: This API is used by twenty-front, not just by twenty-website
// Make sure you don't change it without updating twenty-front at the same time
export async function getReleases(baseUrl?: string): Promise<ReleaseNote[]> {
const files = fs.readdirSync('src/content/releases');
const releasenotes: ReleaseNote[] = [];
for (const fileName of files) {
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
continue;
}
const file = fs.readFileSync(`src/content/releases/${fileName}`, 'utf-8');
const { data: frontmatter, content } = matter(file);
let updatedContent;
if(baseUrl) {
updatedContent = content.replace(/!\[(.*?)\]\((?!http)(.*?)\)/g, (match, alt, src) => {
// Check if src is a relative path (not starting with http:// or https://)
if (!src.startsWith('/')) {
src = `${baseUrl}/${src}`;
} else {
src = `${baseUrl}${src}`;
}
return `![${alt}](${src})`;
});
}
releasenotes.push({
slug: fileName.slice(0, -4),
date: frontmatter.Date,
release: frontmatter.release,
content: updatedContent ?? content,
});
}
releasenotes.sort((a, b) => compareSemanticVersions(b.release, a.release));
return releasenotes;
}
export async function GET(request: NextRequest) {
const host = request.nextUrl.hostname;
const protocol = request.nextUrl.protocol;
const baseUrl = `${protocol}//${host}`;
console.log(baseUrl);
return NextResponse.json(await getReleases(baseUrl), { status: 200 })
return NextResponse.json(await getReleases(baseUrl), { status: 200 });
}

View File

@ -0,0 +1,47 @@
import fs from 'fs';
import matter from 'gray-matter';
import { ReleaseNote } from '@/app/releases/api/route';
import { compareSemanticVersions } from '@/shared-utils/compareSemanticVersions';
// WARNING: This API is used by twenty-front, not just by twenty-website
// Make sure you don't change it without updating twenty-front at the same time
export async function getReleases(baseUrl?: string): Promise<ReleaseNote[]> {
const files = fs.readdirSync('src/content/releases');
const releasenotes: ReleaseNote[] = [];
for (const fileName of files) {
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
continue;
}
const file = fs.readFileSync(`src/content/releases/${fileName}`, 'utf-8');
const { data: frontmatter, content } = matter(file);
let updatedContent;
if (baseUrl) {
updatedContent = content.replace(
/!\[(.*?)\]\((?!http)(.*?)\)/g,
(match: string, alt: string, src: string) => {
// Check if src is a relative path (not starting with http:// or https://)
if (!src.startsWith('/')) {
src = `${baseUrl}/${src}`;
} else {
src = `${baseUrl}${src}`;
}
return `![${alt}](${src})`;
},
);
}
releasenotes.push({
slug: fileName.slice(0, -4),
date: frontmatter.Date,
release: frontmatter.release,
content: updatedContent ?? content,
});
}
releasenotes.sort((a, b) => compareSemanticVersions(b.release, a.release));
return releasenotes;
}

View File

@ -5,7 +5,7 @@ import { Line } from '@/app/_components/releases/Line';
import { Release } from '@/app/_components/releases/Release';
import { Title } from '@/app/_components/releases/StyledTitle';
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
import { getReleases } from '@/app/releases/api/route';
import { getReleases } from '@/app/releases/get-releases';
export const metadata: Metadata = {
title: 'Twenty - Releases',

View File

@ -49,6 +49,18 @@ const migrate = async () => {
throw new Error('Unsupported database driver');
};
const findOne = (model: SQLiteTableWithColumns<any>, orderBy: any) => {
if (isSqliteDriver) {
return sqliteDb.select().from(model).orderBy(orderBy).limit(1).execute();
}
if (isPgDriver) {
return pgDb.select().from(model).orderBy(orderBy).limit(1).execute();
}
throw new Error('Unsupported database driver');
};
const findAll = (model: SQLiteTableWithColumns<any>) => {
if (isSqliteDriver) {
return sqliteDb.select().from(model).all();
@ -92,4 +104,4 @@ const insertMany = async (
throw new Error('Unsupported database driver');
};
export { findAll, insertMany, migrate };
export { findAll, findOne, insertMany, migrate };

View File

@ -1,16 +1,16 @@
export function compareSemanticVersions(a: string, b: string) {
const a1 = a.split('.');
const b1 = b.split('.');
const len = Math.min(a1.length, b1.length);
for (let i = 0; i < len; i++) {
const a2 = +a1[i] || 0;
const b2 = +b1[i] || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
const a1 = a.split('.');
const b1 = b.split('.');
const len = Math.min(a1.length, b1.length);
for (let i = 0; i < len; i++) {
const a2 = +a1[i] || 0;
const b2 = +b1[i] || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
return b1.length - a1.length;
}
}
return b1.length - a1.length;
}