Maxence Pellouin

Junior Software engineer | Lyon, France

Get to know more about me in the about section.


Unified Contributions Viewer From Github and Gitlab

Published May 13, 2024

After a small hiatus due to exams and job interviews, I wanted to come back with a new project that I’ve been working on for the past few days. Due to the fact that sometime organizations use another platform than the one you use to host your repositories, I wanted to create a unified contributions viewer that would show contributions from both Github and Gitlab.

The idea is to have a github-like contributions graph that would show contributions from both platforms so that you can have a better overview of your contributions. This can also benefit you for job interviews as not only you can show that you contributed to both platforms but you can also demonstrate that your contributions are consistent despite them being low since you are working professionally on another platform.

In the near future, I will probably add Bitbucket to the list as well as other platforms that could be interesting.

The code is open-source and available on Github. I decided to use NestJS as the backend as I wanted to quickly create a REST API that would fetch the contributions from the different platforms and return them in a unified format.

We will also see how exactly you can use this project to update automatically your contributions graph on your Github profile using Github Actions.

Getting contributions from Github and Gitlab

Thankfully, both platforms provide us easy ways to get the contributions of a user.

Github

Github allows us to use it’s graphql API to get the contributions of a user. The explorer also allows us for interactive query testing which is really useful to get and determine the fields we need.

By providing a login and the following query, we can get the contributions of a user:

{
  user(login: "mpellouin") {
    contributionsCollection {
      contributionCalendar {
        weeks {
          contributionDays {
            contributionCount
            date
          }
        }
      }
    }
  }
}

Let’s take a look at what Github’s API returns:

{
  "data": {
    "user": {
      "contributionsCollection": {
        "contributionCalendar": {
          "weeks": [
            {
              "contributionDays": [
                {
                  "contributionCount": 0,
                  "date": "2023-05-14T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 6,
                  "date": "2023-05-15T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 8,
                  "date": "2023-05-16T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 3,
                  "date": "2023-05-17T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 0,
                  "date": "2023-05-18T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 2,
                  "date": "2023-05-19T00:00:00.000+00:00"
                },
                {
                  "contributionCount": 0,
                  "date": "2023-05-20T00:00:00.000+00:00"
                }
              ]
            },
            {
              "contributionDays": [
                ...
              ]
            },
            ...
          ]
        }
      }
    }
  }
}

The fact that we are using a login and not the viewer is important as we want to get the contributions of a specific user and not the viewer’s contributions. However, if you want to get the viewer’s contributions, you can use the viewer query instead of the user query. This will not be important as we are using github’s personal access token to get the contributions.

Gitlab

Gitlab provides a route on it’s API to get the contributions directly from the calendar. This is really useful as we don’t need to do any additional processing to get the contributions.

By providing the username and the following route, we can get the contributions of a user:

curl https://gitlab.com/users/mpellouin/calendar.json                                                                                                                                                                         
{"2023-05-17":5,"2023-05-18":8,"2023-05-20":18,"2023-05-21":8,"2023-05-25":9,"2023-05-26":20,"2023-05-27":1,"2023-05-29":110,"2023-06-01":18,"2023-06-04":18,"2023-06-07":39,"2023-06-08":53,"2023-06-09":25,"2023-06-12":7,"2023-06-13":5,"2023-06-18":6,"2023-06-22":6,"2023-06-30":1,"2023-07-05":2,"2023-07-19":2,"2023-08-30":2,"2023-09-03":3,"2023-09-04":3,"2023-09-25":16,"2023-09-26":1,"2023-09-29":1,"2023-09-30":12,"2023-10-01":41,"2023-10-02":58,"2023-10-03":43,"2023-10-04":1,"2023-10-11":8,"2023-10-16":12,"2023-11-07":10,"2023-11-08":3,"2023-11-09":7,"2023-11-13":1,"2023-11-19":12,"2023-11-20":1,"2023-11-27":9,"2023-11-28":15,"2023-11-29":11,"2023-11-30":15,"2023-12-05":1,"2023-12-06":2,"2023-12-07":1,"2023-12-08":1,"2024-01-01":5,"2024-01-02":25,"2024-01-03":37,"2024-02-04":2,"2024-02-05":1,"2024-02-06":1,"2024-02-25":16,"2024-02-26":9,"2024-03-01":7,"2024-03-04":6,"2024-03-05":2,"2024-03-07":14,"2024-03-15":1,"2024-03-18":2,"2024-03-21":1,"2024-03-27":2,"2024-04-02":5,"2024-04-04":1,"2024-04-05":29,"2024-04-09":13,"2024-04-11":3,"2024-04-12":6,"2024-04-16":8,"2024-04-17":8,"2024-04-18":1,"2024-04-19":1,"2024-04-20":2,"2024-04-28":12,"2024-05-01":20,"2024-05-02":21,"2024-05-03":3,"2024-05-05":13,"2024-05-06":41,"2024-05-07":7}

Once again this toute is public and doesn’t require any authentication as it is the route fetched by the Gitlab’s contributions graph.

Creating the unified contributions viewer

After fetching the contributions from both platforms, I decided to create two different endpoints on the backend to get or visualize the contributions.

The first endpoint is /contributions?github_id=<github_id>&gitlab_id=<gitlab_id> which will return the contributions of the user from both platforms as a JSON object.

The second endpoint is /contributions/heatmap?github_id=<github_id>&gitlab_id=<gitlab_id> which returns the contributions as a heatmap in a github fashioned way.

However, since I had never built heatmaps outside of python and the matplotlib and seaborn libraries, I had to do some research on how to create a heatmap in javascript. And then I decided to go with Plotly as it is a really powerful library that allows us to create a lot of different types of graphs.

Creating the heatmap

To create the heatmap we first need to merge the contributions from both platforms into a single object. After normalizing the contributions, we can do this by using the following function of the ContributionsService:

computeTotalContributions(contributionsA: NormalizedContributions, contributionsB: NormalizedContributions): NormalizedContributions {
    const data = { ...contributionsA };

    for (const date in contributionsB) {
      if (data[date]) {
        data[date] += contributionsB[date];
      } else {
        data[date] = contributionsB[date];
      }
    }

    return data;
  }

This allows to add the contributions of the second object to the first object while making sure to not override the contributions of the first object.

Now we need to understand how to create a heatmap with Plotly. By looking at the examples and the documentation, we quickly understand that if we want to create a heatmap like the one on Github, we need to create a 2D array where each row is a day and each column is a week. This is because Plotly will accept the data just like it is displayed.

Therefore, since in our intended design (shown below) is expected to have each row as a day and each column as a week, we need to rearrange the data to fit this format.

heatmap

This is done by the following function of the ContributionsService:

computeHeatmapData(data: NormalizedContributions): (number | null)[][] {
    const contributionsPerDay = [[], [], [], [], [], [], []];
    Object.keys(data).forEach((date) => {
      const testDate = parse(date, 'yyyy-MM-dd', new Date());
      const day = testDate.getDay();
      if (data[date] === 0) contributionsPerDay[day].push(null);
      else
      contributionsPerDay[day].push(data[date]);
    });

    // Normalization of data so that the heatmap is correctly displayed
    const firstContributionDay = parse(Object.keys(data)[0], 'yyyy-MM-dd', new Date()).getDay();
    const lastContributionDay = parse(Object.keys(data)[Object.keys(data).length - 1], 'yyyy-MM-dd', new Date()).getDay();
    
    for (let i = 0; i < firstContributionDay; i++) {
      contributionsPerDay[i].unshift(null);
    }

    for (let i = lastContributionDay + 1; i < 7; i++) {
      contributionsPerDay[i].push(null);
    }

    return contributionsPerDay.reverse();
  }

Let me further break down the function:

  1. We create an array of 7 arrays that will represent the days of the week.
  2. We iterate over the contributions and add them to the correct day of the week while making sure to add null if there are no contributions. This is key since if we do not add null, the heatmap will not be displayed correctly or some squares will be displayed as if we had contributions.
  3. We then need to “normalize” the data so that the heatmap is correctly displayed. Since the contributions retrieved from the platforms date up to 365 days, if the first date retrieved is a Tuesday, then the displayed data would be Sunday, Monday from week 2 followed by the other days from week 1, and this would continue up to the current week. That is why we need to add some null values to the days that are not in the first week and the last week.

From there, we have the data needed to create the heatmap with the createHeatmap method:

async createHeatmap(data: NormalizedContributions, githubId: string, gitlabId: string): Promise<string> {
    let returnURL = '';
    const contributionsPerDay = this.computeHeatmapData(data);
    const totalContributions = Object.values(data).reduce((acc, val) => acc + val, 0);
    const heatmapData = [
      {
        z: contributionsPerDay,
        y: ['Sat', 'Fri', 'Thu', 'Wed', 'Tue', 'Mon', 'Sun'],
        x: this.generateDateLegend(),
        type: 'heatmap',
        colorscale: 'Greens',
        xgap: 2,
        ygap: 2,
      }
    ]
    const layout = {
      title: {
        text: `Github user ${githubId} and Gitlab user ${gitlabId} total contributions: ${totalContributions}`,
        font: {
          color: '#EEEEEE'
        }
      },
      yaxis: {
        tickvals: [0, 2, 4, 6],
        color: '#EEEEEE'
      },
      xaxis: {
        dtick: 2,
        color: '#EEEEEE'

      },
      width: 1000,
      height: 300,
      xgap: 1,
      paper_bgcolor: '#31363F',
      plot_bgcolor: '#222831',
      font: {
        color: '#EEEEEE'
      }
    }
    const graphOptions = {
      fileopt: 'overwrite',
      filename: githubId + '_' + gitlabId,
      layout
    };

    await new Promise((resolve, reject) => {
      this.plotly.plot(heatmapData, graphOptions, function (err, msg) {
          if (err) {
              reject(err);
          } else {
              resolve(msg.url + '.png');
          }
      });
    }).then((url: string) => {
      returnURL = url;
    }).catch((error) => {
      console.error(error);
      throw new InternalServerErrorException("Error while creating heatmap");
    });

    return returnURL;
  }

This method will create the heatmap and return the URL of the heatmap that can then be used to display the heatmap on some frontend, or to automatically update the contributions graph on Github.

Automatically updating the contributions graph on Github with Github Actions

The final idea I had was to automatically update some README.md file on a repository with the heatmap of the contributions. Let’s see how we can do just that by setting up a cron job with Github Actions.

What is a cron job?

A cron job is used to schedule tasks to run periodically at fixed times or intervals. It is commonly used to automate system maintenance or administration. However, we can also use it to automate the update of the contributions graph on Github.

A cron expression is a string that consists of at least five fields separated by white spaces. It looks like this:

* * * * * *

where each one of the fields represents a different time unit. The fields are as follows:

  • Minutes
  • Hours
  • Day of the month
  • Month
  • Day of the week

Therefore the cron expression 0 0 * * * would run the task every day at midnight while the cron expression 0 0 * * 0 would run the task every Sunday at midnight or while the cron expression */5 * * * 0 would run the task every 5 minutes on Sunday.

Our Github Actions workflow

Setting up the workflow

To set up the workflow, we need to create a .github/workflows directory at the root of the repository and create a update-contributions.yml file in it. The workflow will be divided in 4 main steps:

  1. Checkout the repository
  2. Install and run the backend
  3. Create the heatmap
  4. Download the heatmap to the repository
name: Update README Image

on:
  schedule:
    - cron: "0 0 * * *"

jobs:
  update_readme_image:
    runs-on: ubuntu-latest
    environment: CI
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Start API server
        run: |
          yarn --frozen-lockfile
          yarn start &
          sleep 10          
        env:
          PLOTLY_USER: ${{ secrets.PLOTLY_USER }}
          PLOTLY_API_KEY: ${{ secrets.PLOTLY_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.TOKEN }}

      - name: Api check
        run: |
          curl http://localhost:3000          

      - name: Generate image link and download
        run: |
          GITHUB_ID="mpellouin"                                                 
          GITLAB_ID="mpellouin"
          API_URL="http://localhost:3000"
          IMAGE_URL="$(curl -s -X GET "$API_URL/contributions/heatmap?github_id=$GITHUB_ID&gitlab_id=$GITLAB_ID")"
          echo $IMAGE_URL
          wget $IMAGE_URL -O new_image.jpg          

      - name: Replace old image in repository
        continue-on-error: true
        run: |
          IMG_PATH="assets/README_image.jpg"
          GITHUB_USER="mpellouin"
          cp new_image.jpg $IMG_PATH
          git config --global user.name $GITHUB_USER
          git config --global user.email '$GITHUB_USER@users.noreply.github.com'
          git add $IMG_PATH
          git commit -m "Automatic update of README image"
          git push          

As you can see we asks Github to run the workflow every day at midnight. The workflow will then checkout the repository, install the backend, create the heatmap, and download the heatmap to the repository. However, those steps will not work out unless we setup secrets and unless you modify the GITHUB_ID, GITLAB_ID and GITHUB_USER variables.

As to why last step continue-on-error: true is used, it is because the workflow will fail if there is no change to commit. It is realistic that on some days, you will not have produced any contributions AND the day 365 days ago was contributionless as well. Therefore, the heatmap state will change from inserting a null value to “normalize” the null value is it is a day more than 365days ago or a future day. This will result in the heatmap being the same as the previous day and therefore no change to commit.

Even though there is no change to commit, the workflow did what it was supposed to do and this problem arises solely because no contributions were made. Therefore, we can safely ignore this error.

Let’s set-up the secrets now so that our workflow is complete:

Setting up the secrets

To set up the secrets, you need to go to the repository settings and then to the secrets tab. There create an environment called CI as we specified in the workflow and add the following secrets:

Once all of these are set-up, TADA! You have a cron job that will update the contributions graph on your Github profile every day at midnight.

automatic_update_readme.jpg

Conclusion

In this post, we saw how to create a unified contributions viewer from Github and Gitlab. We also saw how to automatically update the contributions graph on your Github profile using Github Actions and cron jobs. I hope this post was useful and that you will be able to use this project or what you learned in your own projects. If you have any questions or suggestions, feel free to leave a comment below. I will be happy to answer them.

In the meantime, be sure to check out and star the repository on github!, and I will see you in the next post!