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

@ -40,12 +40,10 @@ if (
) {
markdown(
getMdSection(
'CLA',
'Welcome!',
`
Hello there and welcome to our project!
By submitting your Pull Request, you acknowledge that you agree with the terms of our [Contributor License Agreement](https://github.com/twentyhq/twenty/blob/main/.github/CLA.md).
Although we don't have a dedicated legal counsel, having this kind of agreement can protect us from potential legal issues or patent trolls.
Thank you for your understanding.`,
Hello there, congrats on your first PR! We're excited to have you contributing to this project.
By submitting your Pull Request, you acknowledge that you agree with the terms of our [Contributor License Agreement](https://github.com/twentyhq/twenty/blob/main/.github/CLA.md).`,
),
);
}

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;
}