Make a video about the best contributor of the month with React and NodeJS šŸš€

Nevo David - Mar 20 '23 - - Dev Community

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 šŸ˜‚

H5

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

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
Enter fullscreen mode Exit fullscreen mode

Once finished, go into the directory

cd contributor-of-the-month
Enter fullscreen mode Exit fullscreen mode

We can now preview Remotion by running

npm run start
Enter fullscreen mode Exit fullscreen mode

Remotion

Now letā€™s go ahead and design our video.

Create a new folder called public and add the following picture:

background

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',
                }}
            />
        </>
    );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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}>
Enter fullscreen mode Exit fullscreen mode

Those are basically the defaultProps we have passed from the previous step, later, we will pass the defaultProps dynamically from JS.

<AbsoluteFill>
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. We will create a new GitHub developers API key that we can use to get information from GitHub.
  2. We will get all the repositories of a GitHub organization.
  3. We will fetch all the merged pull requests of the GitHub repository until the last one of the month.
  4. We will count the contributors with the most amount of merged pull requests.
  5. 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:

  1. moment - so we can check that the time of the pull request is within the month.
  2. @octokit/rest - to send GraphQL requests to GitHub.

So letā€™s install them

npm install moment @types/moment @octokit/rest --save
Enter fullscreen mode Exit fullscreen mode

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:

Token

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
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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
                    }
                }
            }
        }`);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Get the first 100 results.
  2. Remove all the bots from the results.
  3. 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.
  4. 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
                  )
                : []),
        ];
    }
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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ā€.
  2. 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
    1. 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.
    2. 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;
    }
Enter fullscreen mode Exit fullscreen mode
  1. We grab all the org and extract only the name from them - we donā€™t need the other parameters.
  2. 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ā€].
  3. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We want to run our renderer every month. So letā€™s install the node scheduler.

npm install node-schedule --save
Enter fullscreen mode Exit fullscreen mode

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,
    });
};
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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,
    });
};
Enter fullscreen mode Exit fullscreen mode

You can start the project by running

npx ts-node src/scheduler.ts
Enter fullscreen mode Exit fullscreen mode

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

Blank

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

Cat

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .