TL;DR
See the cover of the article? We are going to create this.
We will take an organization on GitHub, review all their repositories, and check the number of merged requests done by every contributor during the month. We will declare the winner by the one with the most amount of pull requests by creating the video š
In my case - I didn't win, and still made the video š
About the technologies
We are going to use Remotion and GitHub GraphQL.
Remotion is an excellent library - most video libraries are very complicated. You need to deal with layers and animate everything by code.
Remotion is different. It lets you write plain React - JSX / CSS and then use web scrapers to record the screen - It sounds hacky - but it's incredible š¤©
We are going to use GitHub GraphQL and not GitHub REST API - it's faster and it has better limits than the REST API.
Novu - the first open-source notification infrastructure
Just a quick background about us. Novu is the first open-source notification infrastructure. We basically help to manage all the product notifications. It can be In-App (the bell icon like you have in the Dev Community - Websockets), Emails, SMSs and so on.
I would be super grateful if you can help us out by starring the library š¤©
https://github.com/novuhq/novu
Let's start
We will start by initiating a new Remotion project by running the command
npx create-video --blank
What would you like to name your video? āŗ contributor-of-the-month
Once finished, go into the directory
cd contributor-of-the-month
We can now preview Remotion by running
npm run start
Now letās go ahead and design our video.
Create a new folder called public and add the following picture:
We will put the face of the contributor in the middle, and the name on top.
Now letās open our Root.tsx file and change to code into this:
import {Composition} from 'remotion';
import {MyComposition} from './Composition';
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="Contributor"
component={MyComposition}
durationInFrames={300}
fps={30}
width={2548}
height={1068}
defaultProps={{
avatarUrl: 'https://avatars.githubusercontent.com/u/100117126?v=4',
name: 'Nevo David',
}}
/>
</>
);
};
letās talk about whatās going on here.
We said that our Frame-Per-Second(fps) is 30 and the durationInFrames is 300.
It means that durationInFrames / fps = seconds of the video = 10 seconds.
We set the width and the height of the video based on our picture from the previous step.
And we put some defaultProps.
We are going to get the best contributor of the month based on GitHub.
When we run Remotion Previewer, we don't have those parameters, so our best option is to give some default parameters and later replace it by the one we send from our NodeJS renderer.
Party = Confetti, how can we show enough respect, without confetti?
Letās install it!
npm install remotion-confetti
open our Composition.tsx and replace it with the following code:
import {AbsoluteFill, Img, staticFile} from 'remotion';
import {Confetti, ConfettiConfig} from 'remotion-confetti';
import {FC} from 'react';
const confettiConfig1: ConfettiConfig = {
particleCount: 200,
startVelocity: 100,
colors: ['#0033ff', '#ffffff', '#00ff33'],
spread: 1200,
x: 1200,
y: 600,
scalar: 3,
};
export const MyComposition: FC<{avatarUrl: string; name: string}> = (props) => {
const {avatarUrl, name} = props;
return (
<AbsoluteFill>
<Confetti {...confettiConfig1} />
<AbsoluteFill>
<h1
style={{
textAlign: 'center',
fontSize: 200,
color: 'white',
float: 'left',
textShadow: '10px 10px 50px #000',
marginTop: -10,
marginLeft: -100
}}
>
{name}
</h1>
</AbsoluteFill>
<AbsoluteFill
style={{
background: 'white',
width: 630,
height: 600,
borderRadius: '100%',
position: 'absolute',
left: 910,
top: 225,
overflow: 'hidden',
}}
>
<Img style={{minWidth: '100%', minHeight: '100%'}} src={avatarUrl} />
</AbsoluteFill>
<Img
src={staticFile('background-contributor.png')}
width={2548}
height={1068}
/>
</AbsoluteFill>
);
};
So we are starting with some imports:
- AbsoluteFill - itās basically a div, but Remotion knows how to work with it.
- Img - itās the same as img, but Remotion knows how to deal with it.
- staticFile - the function to load our background image
- Confetti - the component we have just installed to show confetti on the screen.
- ConfettiConfig - Configuration to pass to our Confetti component*.*
Next, letās talk about the component:
FC<{avatarUrl: string; name: string}>
Those are basically the defaultProps we have passed from the previous step, later, we will pass the defaultProps dynamically from JS.
<AbsoluteFill>
We have placed multiple AbsoluteFill with inline styling around the document to place the elements on the document such as the contributor name and contributor picture.
const confettiConfig1: ConfettiConfig = {
particleCount: 200,
startVelocity: 100,
colors: ['#0033ff', '#ffffff', '#00ff33'],
spread: 1200,
x: 1200,
y: 600,
scalar: 3,
};
ConfettiConfig - I have put a high velocity to see particles all around the screen, and some colors that are different than the dots we have on the screen.
I have placed it in the middle behind the avatar picture using the x and y, and I have put 3 in the scalar to make the particles a little bit bigger.
From here, you can already see inside the preview that we have a final animation of our contributor š„³
I donāt know about you, but this was super easy.
Now comes the harder part.
Getting the information from GitHub
We are going to extract our contributor of the month.
And here is how itās going to go:
- We will create a new GitHub developers API key that we can use to get information from GitHub.
- We will get all the repositories of a GitHub organization.
- We will fetch all the merged pull requests of the GitHub repository until the last one of the month.
- We will count the contributors with the most amount of merged pull requests.
- We will send those parameters into our Remotion project and render it.
I hope you are excited! Letās start! š
We need to install two more libraries:
- moment - so we can check that the time of the pull request is within the month.
- @octokit/rest - to send GraphQL requests to GitHub.
So letās install them
npm install moment @types/moment @octokit/rest --save
Now, letās go to GitHub and create our token.
Head over to your tokens in settings
https://github.com/settings/tokens
create a new classic token with the following permissions:
Click on āGenerate Tokenā and copy the requested key.
Letās create the GitHub service and the root of our scheduler.
touch scheduler.ts
mkdir services
cd services
touch github.ts
Letās open our github.ts file and add some code.
First, we will import Octokit and set our token from the previous step:
const rest = new Octokit({
auth: "token",
});
Now letās create a new class and create our first static function to get all the repositories from the organization:
export class GitHubAPI {
public static getOrganization() {
return rest.graphql(`{
organization(login: "novuhq") {
id
name
login
url
avatarUrl
repositories(first: 100, privacy: PUBLIC, isFork: false) {
totalCount
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
nodes {
id
name
description
url
stargazerCount
}
}
}
}`);
}
}
Pretty straightforward function. It will get all the repositories from the Novu organization - you can change it to any org you want. In the final code (repository), I have put an example using dotenv.
Now letās create our next function for getting all the contributors from the repository.
Here, it can be a little tricky.
We can only take a maximum of 100 results every time.
We are going to do the following:
- Get the first 100 results.
- Remove all the bots from the results.
- If the last element of the array is in a date within this month, we will get the next 100 results in a recursive way.
- Repeat the process until the last results are not within our month.
static async topContributorOfRepository(
repo: string,
after?: string
): Promise<Array<{avatarUrl: string; login: string}>> {
const allPulls = await rest.graphql(`
query {
repository(name: "${repo}", owner: "novuhq") {
pullRequests(states: [MERGED], ${
after ? `after: "${after}",` : ''
} first: 100, orderBy: {field: CREATED_AT, direction: DESC}){
pageInfo {
endCursor
hasNextPage
}
nodes {
createdAt
author {
login
url
avatarUrl
}
}
}
}
}
`);
const filterArray = allPulls.repository.pullRequests.nodes.filter(
(n) =>
moment(n.createdAt).add(1, 'month').isAfter(moment()) &&
n?.author?.url?.indexOf('/apps/') === -1 &&
n?.author?.url
);
return [
...filterArray.map((p) => ({
login: p.author.login,
avatarUrl: p.author.avatarUrl,
})),
...(allPulls.repository.pullRequests.nodes.length &&
moment(allPulls.repository.pullRequests.nodes.slice(-1)[0].createdAt)
.add(1, 'month')
.isAfter(moment()) &&
allPulls.repository.pullRequests.pageInfo.hasNextPage
? await GitHubAPI.topContributorOfRepository(
repo,
allPulls.repository.pullRequests.pageInfo.endCursor
)
: []),
];
}
So our function has two parameters, ārepoā and āafterā.
Since we will iterate over all the repositories from the first step, we need a ārepoā parameter.
āafterā is for the recursive part, every-time we pass it, we will get the next 100.
In await rest.graphql
we can see the GraphQL code.
We are passing the repo name, org name, and states - we want only merged requests (pull requests), and we tell them to order it by the time they were created in descending order.
After that, we have the filterArray
variables that filters all the results with the following parameters:
- Not a bot - we can know if the user is a bot if inside their URL, they have /apps/, users URLs are usually āgithub.com/nevo-davidā.
- Date matching - we check that itās within the month by adding the createdAt, one month and checking if itās greater than today.
Letās say we are in March. Here are a few examples
- If the pull request was on 10 March, we add it one month. It will be on 10 April, itās bigger than our current date, and itās valid.
- If the pull request was on 10 January, we add it one month. It will be on 10 February, smaller than our current date, and invalid.
After we have filtered the results, we will return a recursive array.
The current filtered array + conditional recursive - if the last item is valid and GitHub says there are more rows to fetch, trigger the function again with the next 100 rows.
Now we just need one more function to merge it all
static async startProcess() {
const orgs = (
await GitHubAPI.getOrganization()
).organization.repositories.nodes.map((p) => p.name);
const loadContributors: Array<{login: string; avatarUrl: string}> = [];
for (const org of orgs) {
loadContributors.push(
...(await GitHubAPI.topContributorOfRepository(org))
);
}
const score = Object.values(
loadContributors.reduce((all, current) => {
all[current.login] = all[current.login] || {
name: current.login,
avatarUrl: current.avatarUrl,
total: 0,
};
all[current.login].total += 1;
return all;
}, {} as {[key: string]: {avatarUrl: string; name: string; total: number}})
).reduce(
(all, current) => {
if (current.total > all.total) {
return current;
}
return all;
},
{name: '', avatarUrl: '', total: -1} as {name: string; total: number}
);
return score;
}
- We grab all the org and extract only the name from them - we donāt need the other parameters.
- We iterate over the names and get all the contributors, we put everything into one array and flat it. It will look something like
[ānevo-davidā, ātomerā, ātomerā, ānevo-davidā, ādimaā].
- In the end, we have another function that merges the results and returns the contributor with the most amount of pull requests.
The full code should look like this:
import {Octokit} from '@octokit/rest';
import moment from 'moment';
const rest. =new Octokit({
auth: key || process.env.GITHUB_TOKEN,
});
export class GitHubAPI {
public static getOrganization() {
return rest.graphql(`{
organization(login: "novuhq") {
id
name
login
url
avatarUrl
repositories(first: 100, privacy: PUBLIC, isFork: false) {
totalCount
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
nodes {
id
name
description
url
stargazerCount
}
}
}
}`);
}
static async topContributorOfRepository(
repo: string,
after?: string
): Promise<Array<{avatarUrl: string; login: string}>> {
const allPulls = await rest.graphql(`
query {
repository(name: "${repo}", owner: "novuhq") {
pullRequests(states: [MERGED], ${
after ? `after: "${after}",` : ''
} first: 100, orderBy: {field: CREATED_AT, direction: DESC}){
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
nodes {
createdAt
author {
login
url
avatarUrl
}
}
}
}
}
`);
const filterArray = allPulls.repository.pullRequests.nodes.filter(
(n) =>
moment(n.createdAt).add(1, 'month').isAfter(moment()) &&
n?.author?.url?.indexOf('/apps/') === -1 &&
n?.author?.url
);
return [
...filterArray.map((p) => ({
login: p.author.login,
avatarUrl: p.author.avatarUrl,
})),
...(allPulls.repository.pullRequests.nodes.length &&
moment(allPulls.repository.pullRequests.nodes.slice(-1)[0].createdAt)
.add(1, 'month')
.isAfter(moment()) &&
allPulls.repository.pullRequests.pageInfo.hasNextPage
? await GitHubAPI.topContributorOfRepository(
repo,
allPulls.repository.pullRequests.pageInfo.endCursor
)
: []),
];
}
static async startProcess() {
const orgs = (
await GitHubAPI.getOrganization()
).organization.repositories.nodes.map((p) => p.name);
const loadContributors: Array<{login: string; avatarUrl: string}> = [];
for (const org of orgs) {
loadContributors.push(
...(await GitHubAPI.topContributorOfRepository(org))
);
}
const score = Object.values(
loadContributors.reduce((all, current) => {
all[current.login] = all[current.login] || {
name: current.login,
avatarUrl: current.avatarUrl,
total: 0,
};
all[current.login].total += 1;
return all;
}, {} as {[key: string]: {avatarUrl: string; name: string; total: number}})
).reduce(
(all, current) => {
if (current.total > all.total) {
return current;
}
return all;
},
{name: '', avatarUrl: '', total: -1} as {name: string; total: number}
);
return score;
}
}
We have our video ready. We have our GitHub code ready. All that is left is to merge them. š±
To use the Remotion server renderer, we need to install it, so letās do it.
npm install @remotion/renderer @remotion/bundler --save
We want to run our renderer every month. So letās install the node scheduler.
npm install node-schedule --save
Letās write our processing function. Itās pretty straightforward :)
const startProcess = async () => {
const topContributor = await GitHubAPI.startProcess();
const bundleLocation = await bundle(
path.resolve('./src/index.ts'),
() => undefined,
{
webpackOverride: (config) => config,
}
);
const comps = await getCompositions(bundleLocation, {
inputProps: topContributor,
});
const composition = comps.find((c) => c.id === 'Contributor')!;
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: 'gif',
outputLocation: 'out/contributor.gif',
inputProps: topContributor,
});
};
First - we use the function to get the top contributor from our GitHub service.
Second - we load the Remotion bundler. We set it to our index.ts file, which is the root of our Remotion video creator.
Third - we load all the compositions (in our case, we have one) and pass the inputProps with what we got from GitHub. (it will replace the defaultProps that we put in the first step).
Forth - We look for our composition - itās silly because we have one, we could just do comps[0]
, but itās more about making a point :)
Fifth - We render the media and create a gif with the contributor of the day. We pass the inputProps again - honestly, this is in the Remotion documentation, and I am not really sure why we need to pass it twice.
All we need to do is to create a schedule to run every 1st of the month and trigger the function.
schedule.scheduleJob('0 0 1 * *', () => {
startProcess();
});
The ā0 0 1 * *ā is a way to write cron, you can easily play with it here.
Here is the full code of the page:
import schedule from 'node-schedule';
import {bundle} from '@remotion/bundler';
import {getCompositions, renderMedia} from '@remotion/renderer';
import path from 'path';
import {GitHubAPI} from './services/github';
schedule.scheduleJob('0 0 1 * *', () => {
startProcess();
});
const startProcess = async () => {
const topContributor = await GitHubAPI.startProcess();
const bundleLocation = await bundle(
path.resolve('./src/index.ts'),
() => undefined,
{
webpackOverride: (config) => config,
}
);
const comps = await getCompositions(bundleLocation, {
inputProps: topContributor,
});
const composition = comps.find((c) => c.id === 'Contributor')!;
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: 'gif',
outputLocation: 'out/contributor.gif',
inputProps: topContributor,
});
};
You can start the project by running
npx ts-node src/scheduler.ts
And you are done š
You can share it over Discord, Twitter, or any channel somebody can see it!
I encourage you to run the code and post your rendered video in the comments š¤©
You can find the source code here:
https://github.com/novuhq/blog/tree/main/contributor-of-the-month
Can you help me?
Creating tutorials takes a lot of time and effort, but it's all worth it when I see the positive impact it has on the developer community.
If you find my tutorials helpful, please consider giving Novu's repository a star. Your support will motivate me to create more valuable content and tutorials. Thank you for your support! āļøāļøāļøāļø
https://github.com/novuhq/novu