diff --git a/.github/workflows/ci-utils.yaml b/.github/workflows/ci-utils.yaml index 29c4e28d08..fccfca98d8 100644 --- a/.github/workflows/ci-utils.yaml +++ b/.github/workflows/ci-utils.yaml @@ -5,6 +5,7 @@ on: # see: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ # and: https://github.com/facebook/react-native/pull/34370/files pull_request_target: + types: [opened, synchronize, reopened, closed] permissions: actions: write checks: write @@ -19,6 +20,7 @@ concurrency: jobs: danger-js: runs-on: ubuntu-latest + if: github.event.action != 'closed' steps: - uses: actions/checkout@v4 - name: Install dependencies @@ -27,3 +29,15 @@ jobs: run: cd packages/twenty-utils && npx nx danger:ci env: DANGER_GITHUB_API_TOKEN: ${{ github.token }} + + congratulate: + runs-on: ubuntu-latest + if: github.event.action == 'closed' && github.event.pull_request.merged == true + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/workflows/actions/yarn-install + - name: Run congratulate-dangerfile.js + run: cd packages/twenty-utils && npx nx danger:congratulate + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/twenty-utils/congratulate-dangerfile.ts b/packages/twenty-utils/congratulate-dangerfile.ts new file mode 100644 index 0000000000..570038387e --- /dev/null +++ b/packages/twenty-utils/congratulate-dangerfile.ts @@ -0,0 +1,109 @@ +import { danger } from 'danger'; + +const ordinalSuffix = (number) => { + const v = number % 100; + if (v === 11 || v === 12 || v === 13) { + return number + 'th'; + } + const suffixes = { 1: 'st', 2: 'nd', 3: 'rd' }; + return number + (suffixes[v % 10] || 'th'); +}; + +const fetchContributorStats = async (username: string) => { + const apiUrl = `https://twenty.com/api/contributors/contributorStats/${username}`; + + const response = await fetch(apiUrl); + const data = await response.json(); + return data; +}; + +const fetchContributorImage = async (username: string) => { + const apiUrl = `https://twenty.com/api/contributors/${username}/og.png`; + + await fetch(apiUrl); +}; + +const getTeamMembers = async () => { + const org = 'twentyhq'; + const team_slug = 'core-team'; + const response = await danger.github.api.teams.listMembersInOrg({ + org, + team_slug, + }); + return response.data.map((user) => user.login); +}; + +const runCongratulate = async () => { + const pullRequest = danger.github.pr; + const userName = pullRequest.user.login; + + const staticExcludedUsers = [ + 'dependabot', + 'cyborch', + 'emilienchvt', + 'Samox', + 'charlesBochet', + 'gitstart-app', + 'thaisguigon', + 'lucasbordeau', + 'magrinj', + 'Weiko', + 'gitstart-twenty', + 'bosiraphael', + 'martmull', + 'FelixMalfait', + 'thomtrp', + 'Bonapara', + 'nimraahmed', + 'ady-beraud', + ]; + + const teamMembers = await getTeamMembers(); + + const excludedUsers = new Set([...staticExcludedUsers, ...teamMembers]); + + if (excludedUsers.has(userName)) { + return; + } + + const { data: pullRequests } = + await danger.github.api.rest.search.issuesAndPullRequests({ + q: `is:pr author:${userName} is:closed repo:twentyhq/twenty`, + per_page: 2, + page: 1, + }); + + const isFirstPR = pullRequests.total_count === 1; + + if (isFirstPR) { + return; + } + + const stats = await fetchContributorStats(userName); + const contributorUrl = `https://twenty.com/contributors/${userName}`; + + // Pre-fetch to trigger cloudflare cache + await fetchContributorImage(userName); + + const message = + `Thanks @${userName} for your contribution!\n` + + `This marks your **${ordinalSuffix( + stats.mergedPRsCount, + )}** PR on the repo. ` + + `You're **top ${stats.rank}%** of all our contributors 🎉\n` + + `[See contributor page](${contributorUrl}) - ` + + `[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=${contributorUrl}) - ` + + `[Share on Twitter](https://www.twitter.com/share?url=${contributorUrl})\n\n` + + `![Contributions](https://twenty.com/api/contributors/${userName}/og.png)`; + + await danger.github.api.rest.issues.createComment({ + owner: danger.github.thisPR.owner, + repo: danger.github.thisPR.repo, + issue_number: danger.github.thisPR.pull_number, + body: message, + }); +}; + +if (danger.github && danger.github.pr.merged) { + runCongratulate(); +} diff --git a/packages/twenty-utils/package.json b/packages/twenty-utils/package.json index e7dce62e38..043895e472 100644 --- a/packages/twenty-utils/package.json +++ b/packages/twenty-utils/package.json @@ -4,6 +4,7 @@ "scripts": { "nx": "NX_DEFAULT_PROJECT=twenty-front node ../../node_modules/nx/bin/nx.js", "danger:ci": "danger ci --use-github-checks --failOnErrors", + "danger:congratulate": "danger ci --dangerfile ./congratulate-dangerfile.ts --use-github-checks --failOnErrors", "release": "node release.js" } } diff --git a/packages/twenty-website/package.json b/packages/twenty-website/package.json index 427e22680f..1c108efe10 100644 --- a/packages/twenty-website/package.json +++ b/packages/twenty-website/package.json @@ -8,8 +8,8 @@ "build": "npx next build", "start": "npx next start", "lint": "npx next lint", - "github:sync": "npx tsx src/github/github-sync.ts --pageLimit 1", - "github:init": "npx tsx src/github/github-sync.ts", + "github:sync": "npx tsx src/github/github-sync.ts", + "github:init": "npx tsx src/github/github-sync.ts --isFullSync", "database:migrate": "npx tsx src/database/migrate-database.ts", "database:generate:pg": "npx drizzle-kit generate:pg --config=src/database/drizzle-posgres.config.ts" }, diff --git a/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx b/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx index 8519309fdf..fcca7a3719 100644 --- a/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx +++ b/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx @@ -55,7 +55,7 @@ interface ProfileProps { export const ProfileSharing = ({ username }: ProfileProps) => { const [loading, setLoading] = useState(false); - const baseUrl = `${window.location.protocol}//${window.location.host}`; + const baseUrl = 'https://twenty.com'; const contributorUrl = `${baseUrl}/contributors/${username}`; const handleDownload = async () => { @@ -101,7 +101,7 @@ export const ProfileSharing = ({ username }: ProfileProps) => { )} Share on X diff --git a/packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx b/packages/twenty-website/src/app/api/contributors/[slug]/og.png/route.tsx similarity index 93% rename from packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx rename to packages/twenty-website/src/app/api/contributors/[slug]/og.png/route.tsx index 17e45b011e..b467cec85c 100644 --- a/packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx +++ b/packages/twenty-website/src/app/api/contributors/[slug]/og.png/route.tsx @@ -2,7 +2,7 @@ import { format } from 'date-fns'; import { ImageResponse } from 'next/og'; import { - bottomBackgroundImage, + backgroundImage, container, contributorInfo, contributorInfoBox, @@ -15,8 +15,7 @@ import { profileInfoContainer, profileUsernameHeader, styledContributorAvatar, - topBackgroundImage, -} from '@/app/api/contributors/og-image/[slug]/style'; +} from '@/app/api/contributors/[slug]/og.png/style'; import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity'; const GABARITO_FONT_CDN_URL = @@ -33,8 +32,10 @@ const getGabarito = async () => { export async function GET(request: Request) { try { const url = request.url; - - const username = url.split('/')?.pop() || ''; + const splitUrl = url.split('/'); + const usernameIndex = + splitUrl.findIndex((part) => part === 'contributors') + 1; + const username = splitUrl[usernameIndex]; const contributorActivity = await getContributorActivity(username); if (contributorActivity) { @@ -45,11 +46,11 @@ export async function GET(request: Request) { activeDays, contributorAvatar, } = contributorActivity; - return await new ImageResponse( + + const imageResponse = await new ImageResponse( (
-
-
+
@@ -59,8 +60,8 @@ export async function GET(request: Request) {
2024-02-27" to make it dynamic - export async function fetchIssuesPRs( query: typeof graphql, cursor: string | null = null, isIssues = false, accumulatedData: Array = [], - pageLimit: number, - currentPage = 0, ): Promise> { const { repository } = await query( ` query ($cursor: String) { repository(owner: "twentyhq", name: "twenty") { - pullRequests(first: 30, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${isIssues}) { + pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${isIssues}) { nodes { id title @@ -94,16 +89,12 @@ export async function fetchIssuesPRs( ? repository.issues.pageInfo : repository.pullRequests.pageInfo; - const newCurrentPage = currentPage + 1; - - if ((!pageLimit || newCurrentPage < pageLimit) && pageInfo.hasNextPage) { + if (pageInfo.hasNextPage) { return fetchIssuesPRs( query, pageInfo.endCursor, isIssues, newAccumulatedData, - pageLimit, - currentPage + 1, ); } else { return newAccumulatedData; diff --git a/packages/twenty-website/src/github/contributors/get-latest-update.tsx b/packages/twenty-website/src/github/contributors/get-latest-update.tsx new file mode 100644 index 0000000000..9b2e03e727 --- /dev/null +++ b/packages/twenty-website/src/github/contributors/get-latest-update.tsx @@ -0,0 +1,15 @@ +import { desc } from 'drizzle-orm'; + +import { findOne } from '@/database/database'; +import { issueModel, pullRequestModel } from '@/database/model'; + +export async function getLatestUpdate() { + const latestPR = await findOne( + pullRequestModel, + desc(pullRequestModel.updatedAt), + ); + const latestIssue = await findOne(issueModel, desc(issueModel.updatedAt)); + const prDate = new Date(latestPR[0].updatedAt); + const issueDate = new Date(latestIssue[0].updatedAt); + return (prDate > issueDate ? prDate : issueDate).toISOString(); +} diff --git a/packages/twenty-website/src/github/contributors/save-issues-to-db.tsx b/packages/twenty-website/src/github/contributors/save-issues-to-db.tsx index 3d7b91d15b..f7cb48e682 100644 --- a/packages/twenty-website/src/github/contributors/save-issues-to-db.tsx +++ b/packages/twenty-website/src/github/contributors/save-issues-to-db.tsx @@ -42,7 +42,10 @@ export async function saveIssuesToDB( authorId: issue.author.login, }, ], - { onConflictKey: 'id' }, + { + onConflictKey: 'id', + onConflictUpdateObject: { updatedAt: issue.updatedAt }, + }, ); for (const label of issue.labels.nodes) { diff --git a/packages/twenty-website/src/github/contributors/save-prs-to-db.tsx b/packages/twenty-website/src/github/contributors/save-prs-to-db.tsx index 4be5f58210..892b5565fc 100644 --- a/packages/twenty-website/src/github/contributors/save-prs-to-db.tsx +++ b/packages/twenty-website/src/github/contributors/save-prs-to-db.tsx @@ -44,7 +44,10 @@ export async function savePRsToDB( authorId: pr.author.login, }, ], - { onConflictKey: 'id', onConflictUpdateObject: { title: pr.title } }, + { + onConflictKey: 'id', + onConflictUpdateObject: { title: pr.title, updatedAt: pr.updatedAt }, + }, ); for (const label of pr.labels.nodes) { diff --git a/packages/twenty-website/src/github/contributors/search-issues-prs.tsx b/packages/twenty-website/src/github/contributors/search-issues-prs.tsx index 03028e3041..8a1b53d9ef 100644 --- a/packages/twenty-website/src/github/contributors/search-issues-prs.tsx +++ b/packages/twenty-website/src/github/contributors/search-issues-prs.tsx @@ -1,5 +1,6 @@ import { graphql } from '@octokit/graphql'; +import { getLatestUpdate } from '@/github/contributors/get-latest-update'; import { IssueNode, PullRequestNode, @@ -12,12 +13,13 @@ export async function searchIssuesPRs( isIssues = false, accumulatedData: Array = [], ): Promise> { + const since = await getLatestUpdate(); const { search } = await query( ` query searchPullRequestsAndIssues($cursor: String) { search(query: "repo:twentyhq/twenty ${ isIssues ? 'is:issue' : 'is:pr' - } updated:>2024-02-27", type: ISSUE, first: 100, after: $cursor) { + } updated:>${since}", type: ISSUE, first: 100, after: $cursor) { edges { node { ... on PullRequest { @@ -80,6 +82,7 @@ export async function searchIssuesPRs( cursor, }, ); + const newAccumulatedData: Array = [ ...accumulatedData, ...search.edges.map(({ node }) => node), diff --git a/packages/twenty-website/src/github/execute-partial-sync.ts b/packages/twenty-website/src/github/execute-partial-sync.ts new file mode 100644 index 0000000000..77922d6734 --- /dev/null +++ b/packages/twenty-website/src/github/execute-partial-sync.ts @@ -0,0 +1,46 @@ +import { graphql } from '@octokit/graphql'; + +import { fetchAssignableUsers } from '@/github/contributors/fetch-assignable-users'; +import { saveIssuesToDB } from '@/github/contributors/save-issues-to-db'; +import { savePRsToDB } from '@/github/contributors/save-prs-to-db'; +import { searchIssuesPRs } from '@/github/contributors/search-issues-prs'; +import { IssueNode, PullRequestNode } from '@/github/contributors/types'; +import { fetchAndSaveGithubReleases } from '@/github/github-releases/fetch-and-save-github-releases'; +import { fetchAndSaveGithubStars } from '@/github/github-stars/fetch-and-save-github-stars'; + +export const executePartialSync = async () => { + if (!global.process.env.GITHUB_TOKEN) { + return new Error('No GitHub token provided'); + } + + console.log('Synching data..'); + + const query = graphql.defaults({ + headers: { + Authorization: 'bearer ' + global.process.env.GITHUB_TOKEN, + }, + }); + + await fetchAndSaveGithubStars(query); + await fetchAndSaveGithubReleases(query); + + const assignableUsers = await fetchAssignableUsers(query); + + const fetchedPRs = (await searchIssuesPRs( + query, + null, + false, + [], + )) as Array; + const fetchedIssues = (await searchIssuesPRs( + query, + null, + true, + [], + )) as Array; + + await savePRsToDB(fetchedPRs, assignableUsers); + await saveIssuesToDB(fetchedIssues, assignableUsers); + + console.log('data synched!'); +}; diff --git a/packages/twenty-website/src/github/fetch-and-save-github-data.ts b/packages/twenty-website/src/github/fetch-and-save-github-data.ts index a0a41f024a..42e902fe14 100644 --- a/packages/twenty-website/src/github/fetch-and-save-github-data.ts +++ b/packages/twenty-website/src/github/fetch-and-save-github-data.ts @@ -9,11 +9,7 @@ import { IssueNode, PullRequestNode } from '@/github/contributors/types'; import { fetchAndSaveGithubReleases } from '@/github/github-releases/fetch-and-save-github-releases'; import { fetchAndSaveGithubStars } from '@/github/github-stars/fetch-and-save-github-stars'; -export const fetchAndSaveGithubData = async ({ - pageLimit, -}: { - pageLimit: number; -}) => { +export const fetchAndSaveGithubData = async () => { if (!global.process.env.GITHUB_TOKEN) { return new Error('No GitHub token provided'); } @@ -35,14 +31,12 @@ export const fetchAndSaveGithubData = async ({ null, false, [], - pageLimit, )) as Array; const fetchedIssues = (await fetchIssuesPRs( query, null, true, [], - pageLimit, )) as Array; await savePRsToDB(fetchedPRs, assignableUsers); diff --git a/packages/twenty-website/src/github/github-sync.ts b/packages/twenty-website/src/github/github-sync.ts index 1c766687e9..4e0e29f59b 100644 --- a/packages/twenty-website/src/github/github-sync.ts +++ b/packages/twenty-website/src/github/github-sync.ts @@ -1,14 +1,16 @@ +import { executePartialSync } from '@/github/execute-partial-sync'; import { fetchAndSaveGithubData } from '@/github/fetch-and-save-github-data'; export const githubSync = async () => { - const pageLimitFlagIndex = process.argv.indexOf('--pageLimit'); - let pageLimit = 0; + const isFullSyncFlagIndex = process.argv.indexOf('--isFullSync'); + const isFullSync = isFullSyncFlagIndex > -1; - if (pageLimitFlagIndex > -1) { - pageLimit = parseInt(process.argv[pageLimitFlagIndex + 1], 10); + if (isFullSync) { + await fetchAndSaveGithubData(); + } else { + await executePartialSync(); } - await fetchAndSaveGithubData({ pageLimit }); process.exit(0); };