Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create congratulations bot #5404

Merged
merged 10 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/congratulate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Congratulate Contributors

on:
# it's usually not recommended to use pull_request_target
# but we consider it's safe here if we keep the same steps
# 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: [closed]
permissions:
pull-requests: write
jobs:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add this as a step in utils instead of a dedicated workflow? It will show up as a new step in CI otherwise which would be a bit strange

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran multiple tests and unfortunately we can’t add the congratulations bot as a new step in CI. Basically as of right now, CI is being triggered for the following types of activity:

  • opened: Triggered when a pull request is opened.
  • synchronize: Triggered when a commit is added to the pull request.
  • reopened: Triggered when a pull request is reopened.

The bot needs to be triggered on the closed event.
We don’t want to add closed for the existing danger file as it wouldn’t make any sense + we don’t want the bot triggered on the 3 other events.
So what we could do to avoid having another workflow is add another job to CI (danger + congratulate). Making sure danger is triggered for 3 events and congratulate for closed only. Basically the job would be skipped for event types that they don’t correspond to. This would allow us to have one workflow but with 2 different jobs. Let me know if you would rather that or have two separate workflows?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think that would be best! Thanks

congratulate:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
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: ${{ github.token }}
72 changes: 72 additions & 0 deletions packages/twenty-utils/congratulate-dangerfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 runCongratulate = async () => {
const pullRequest = danger.github.pr;
const userName = pullRequest.user.login;

const { data: pullRequests } =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to be posted on every PR? We want to discard the assignableUsers + a list (same as get-contributor activity / page.tsx - search 'nimraahmed' ; there should be just one source of truth for non-contributors)

await danger.github.api.rest.search.issuesAndPullRequests({
q: `is:pr author:${userName} is:closed repo:twentyhq/twenty`,
per_page: 2,
page: 1,
});

let stats;
const isFirstPR = pullRequests.total_count === 1;

if (!isFirstPR) {
stats = await fetchContributorStats(userName);
} else {
stats = { mergedPRsCount: 1, rank: 52 };
}

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` +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe hide this is if it's their first contribution at least? Feels a bit strange otherwise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed !

`[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();
}
1 change: 1 addition & 0 deletions packages/twenty-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ interface ProfileProps {
}

export const ProfileSharing = ({ username }: ProfileProps) => {
const baseUrl = `${window.location.protocol}//${window.location.host}`;
const baseUrl = 'https://twenty.com';

const contributorUrl = `${baseUrl}/contributors/${username}`;

const handleDownload = async () => {
Expand Down Expand Up @@ -87,7 +88,7 @@ export const ProfileSharing = ({ username }: ProfileProps) => {
<IconDownload /> Download Image
</StyledButton>
<StyledButton
href={`http://www.twitter.com/share?url=${contributorUrl}`}
href={`https://www.twitter.com/share?url=${contributorUrl}`}
target="blank"
>
<XIcon color="black" size="24px" /> Share on X
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { format } from 'date-fns';
import { ImageResponse } from 'next/og';

import {
bottomBackgroundImage,
backgroundImage,
container,
contributorInfo,
contributorInfoBox,
Expand All @@ -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 =
Expand All @@ -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) {
Expand All @@ -45,11 +46,11 @@ export async function GET(request: Request) {
activeDays,
contributorAvatar,
} = contributorActivity;
return await new ImageResponse(

const imageResponse = await new ImageResponse(
(
<div style={container}>
<div style={topBackgroundImage}></div>
<div style={bottomBackgroundImage}></div>
<div style={backgroundImage}></div>
<div style={profileContainer}>
<img src={contributorAvatar} style={styledContributorAvatar} />
<div style={profileInfoContainer}>
Expand All @@ -59,8 +60,8 @@ export async function GET(request: Request) {
</h2>
</div>
<svg
width="96"
height="96"
width="134"
height="134"
viewBox="0 0 136 136"
fill="none"
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -122,6 +123,7 @@ export async function GET(request: Request) {
],
},
);
return imageResponse;
}
} catch (error) {
return new Response(`error: ${error}`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,42 +14,36 @@ export const container: CSSProperties = {
fontFamily: 'Gabarito',
};

export const topBackgroundImage: CSSProperties = {
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
export const backgroundImage: CSSProperties = {
position: 'absolute',
zIndex: '-1',
width: '1300px',
height: '250px',
transform: 'rotate(-11deg)',
opacity: '0.2',
top: '-100',
left: '-25',
};

export const bottomBackgroundImage: CSSProperties = {
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
position: 'absolute',
zIndex: '-1',
width: '1300px',
height: '250px',
transform: 'rotate(-11deg)',
opacity: '0.2',
bottom: '-120',
right: '-40',
width: '1250px',
height: '850px',
transform: 'rotate(-7deg)',
opacity: '0.8',
backgroundImage: `
linear-gradient(
158.4deg,
rgba(255, 255, 255, 0.8) 30.69%,
#FFFFFF 35.12%,
rgba(255, 255, 255, 0.8) 60.27%,
rgba(255, 255, 255, 0.64) 38.88%
),
url(${BACKGROUND_IMAGE_URL})`,
};

export const profileContainer: CSSProperties = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
width: '780px',
margin: '0px 0px 40px',
width: '970px',
height: '134px',
margin: '0px 0px 55px',
};

export const styledContributorAvatar = {
display: 'flex',
width: '96px',
height: '96px',
width: '134px',
height: '134px',
margin: '0px',
border: '3px solid #141414',
borderRadius: '16px',
Expand All @@ -65,7 +59,7 @@ export const profileInfoContainer: CSSProperties = {

export const profileUsernameHeader: CSSProperties = {
margin: '0px',
fontSize: '28px',
fontSize: '39px',
fontWeight: '700',
color: '#141414',
fontFamily: 'Gabarito',
Expand All @@ -74,7 +68,7 @@ export const profileUsernameHeader: CSSProperties = {
export const profileContributionHeader: CSSProperties = {
margin: '0px',
color: '#818181',
fontSize: '20px',
fontSize: '27px',
fontWeight: '400',
};

Expand All @@ -84,8 +78,8 @@ export const contributorInfoContainer: CSSProperties = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-around',
width: '780px',
height: '149px',
width: '970px',
height: '209px',
backgroundColor: '#F1F1F1',
};

Expand All @@ -110,21 +104,21 @@ export const contributorInfoTitle = {
color: '#B3B3B3',
margin: '0px',
fontWeight: '500',
fontSize: '24px',
fontSize: '33px',
};

export const contributorInfoStats = {
color: '#474747',
margin: '0px',
fontWeight: '700',
fontSize: '40px',
fontSize: '55px',
};

export const infoSeparator: CSSProperties = {
position: 'absolute',
right: 0,
display: 'flex',
width: '2px',
height: '85px',
height: '120px',
backgroundColor: '#141414',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';

export const dynamic = 'force-dynamic';

export async function GET(request: Request) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that this isn't going to be accurate at the moment it's called with our cron? or will it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I had already thought this through. In my opinion it wouldn’t be an issue because the interval between the creation of a PR, its review, and subsequent merging is likely to be longer than the 5-minute interval of the scheduled cron job.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would if we were showing "Total PRs" but we show "Merged PRs" on the card.
Maybe we should show Total PRs instead? It emphasizes quality even less but I guess that's the best quickfix I can think of :(.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it's problematic ! Okay I can change it to "TotalPRs" for now until a better solution is found

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rank would also have to be changed as it is based on mergedPRs :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arf then maybe expose an endpoint which you call before pre-fetching the image to launch a re-import for that user? and we keep range/total merged PRs

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a better solution (or plug Github webhooks)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance we can do a github:sync ? How often do we merge PRs from contributors (for rate limits)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes sharing the code from github:sync works, then we'll re-expose it as an endpoint! We can keep both
I was thinking of adding an additional param to limit the sync (had we kept the searchIssue partial sync mechanism it would have been one more search parameter in the partial sync, probably also possible in the current script - but ok to not do it if it happens to be complex)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay perfect !

try {
const url = request.url;

const username = url.split('/')?.pop() || '';

const contributorActivity = await getContributorActivity(username);

if (contributorActivity) {
const mergedPRsCount = contributorActivity.mergedPRsCount;
const rank = contributorActivity.rank;
return Response.json({ mergedPRsCount, rank });
}
} catch (error: any) {
return new Response(`Contributor stats error: ${error?.message}`, {
status: 500,
});
}
}
3 changes: 2 additions & 1 deletion packages/twenty-website/src/app/contributors/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ export function generateMetadata({
params: { slug: string };
}): Metadata {
return {
metadataBase: new URL(`https://twenty.com`),
title: 'Twenty - ' + params.slug,
description:
'Explore the impactful contributions of ' +
params.slug +
' on the Twenty Github Repo. Discover their merged pull requests, ongoing work, and top ranking. Join and contribute to the #1 Open-Source CRM thriving community!',
openGraph: {
images: [`/api/contributors/og-image/${params.slug}`],
images: [`https://twenty.com/api/contributors/${params.slug}/og.png`],
},
};
}
Expand Down
Loading