DEV Community

Ibrahiem Mohammad
Ibrahiem Mohammad

Posted on

Creating a Github Action that adds a Pokemon Sprite to your repo README

TL;DR

Add a Pokemon Sprite to your repo READMEs for fun!

Image description

Action
Example Usage

This Christmas break, I had some time on my hands so I decided to write a Github Action that adds a fun pokemon sprite to your repo's README.md. I wrote this action in javascript using node-fetch, octokit/core, and actions/core. The sprite images are fetched from PokeAPI. I had a lot of fun learning more about javascript promises and Github Actions workflows.


Acknowledgements

I'd like to shout out Athul for his work on waka-readme, which first made me realize that Github Actions could even be used in such a cool way.

I'd also like to shout out Geraldine for her tutorial on how to update README files with Github Actions.

Lastly, shout out to Github for making great documentation on how to build an Action with Javascript.

Getting Started

Requirements

Install the following software:

  1. Node16.x
  2. npm

Creating the project directory

First thing you'll need after installing node and npm is to actually setup the project files:

# create the project directory
mkdir poke-readme-action
cd poke-readme-action

# initialize node project install node modules
npm init
npm install --save node-fetch@v2 @actions/core @octokit/core @vercel/ncc
touch index.js
Enter fullscreen mode Exit fullscreen mode

Now in index.js you will grab some test data from the PokeAPI.

const fetch = require('node-fetch');
const pokemon = process.argv.slice(2)[0];
fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`)
        .then((response) => response.json())
        .then((data) => { 
            console.log(data);
        });
Enter fullscreen mode Exit fullscreen mode

Let's test this connection out:

node index.js pikachu
Enter fullscreen mode Exit fullscreen mode

This prints a JSON data block (I've shortened the block so you can see the data you want--the pokemon sprite images):

{
  ...
  sprites: {
    back_default: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/25.png',
    back_female: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/female/25.png',
    back_shiny: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/25.png',
    back_shiny_female: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/back/shiny/female/25.png',
    front_default: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png',
    front_female: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/female/25.png',
    front_shiny: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/25.png',
    front_shiny_female: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/female/25.png',
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now you can update the code to filter out just the sprite url from the rest of the data

fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`)
        .then((response) => response.json())
        .then((data) => { 
            const spriteUrl = data.sprites.front_default;
        });
Enter fullscreen mode Exit fullscreen mode

Javascript Promises 😖

One thing about Javascript that always eluded my understanding was the concept of Promises. As I sat down to figure out how to arrange the order of operations, I realized I needed to understand how Promises worked in order to create a proper data flow.

You know from the previous test that you can use node-fetch to retrieve a Promise object. Once retrieved, you can store it, but a Promise object in itself doesn't help much. You need to unpack the JSON data inside it, and that's where the then() function comes in.

But what's next? Do you pass the sprite data to our next set of operations? Do you store the sprite data asynchronously and call a different set of operations that use the stored sprite data?

Its worth experimenting just to learn how promises work, but for the sake of the blog post, I'll move ahead with what worked for me--to pass the sprite data on to the next set of operations (which I will need to define).

First thing's first: establish the order of operations

  1. Fetch sprite url from PokeAPI
  2. Create markdown snippet with sprite url
  3. Get README.md file in repo
  4. Add and commit new markdown snippet in repo

You already achieved step one previously, so let's hit the next 3!

Setting up the connection to Octokit Core

In order to make Github API calls, you have a few different libraries available, but I found octokit/core to be the easiest to work with and it naturally adheres to the REST API documentation. You'll also need actions/core to pull data from our Github Actions Workflow.

Let's set up the modules and some constants:

const fetch = require('node-fetch');
const core = require('@actions/core');
const octocore = require("@octokit/core")

const pokemon = core.getInput('POKEMON');
const repo_owner = core.getInput('REPOSITORY_OWNER')
const gh_token = core.getInput('GH_TOKEN');
const commit_message = core.getInput('COMMIT_MESSAGE');
const repo_name = core.getInput('REPOSITORY');

const octocore_client = new octocore.Octokit({auth: gh_token})
Enter fullscreen mode Exit fullscreen mode

The above inputs will all be defined later in our Actions YAML file, so for now all you need to know is that these variables will help us connect to the Github API and run some CRUD operations.

Completing the Action

Now that you have our main piece of data (the pokemon sprite url), and our necessary inputs defined, let's get to work on completing the action code.

First you'll start by creating a function to get the README.md file from the repo:

function getReadme(){
    return octocore_client.request(`GET /repos/${repo_owner}/${repo_name}/contents/README.md`)
}
Enter fullscreen mode Exit fullscreen mode

The API call above constructs a get request to get the file given repo_name and repo_owner.

Bug Alert
Running this code will actually result in a bug due to the inputs.

The API call constructs the url as such: /repos/ibrahiem96/ibrahiem96/ibrahiem96/contents/README.md

This is because the repo_owner gets stored as: ibrahiem96, but the repo name gets stored as: ibrahiem96/ibrahiem96

So you need to add some code that can handle this exception:

const repo = (repoName) => {
    if (repoName.includes('/')) {
        return repoName.split('/')[1]
    }
    else return repoName;
};

const repo_name = repo(core.getInput('REPOSITORY'));
Enter fullscreen mode Exit fullscreen mode

This is a short lambda function that takes in the repo name input and returns the repository name after the slash (ex: ibrahiem96/slybot would return only slybot.

Now that you took care of this, our getReadme() function should work fine, and you can use it in our next function: updateReadme().

The updateReadme() function will need to do the following things:

  1. Write out the raw content of the README.md
  2. Append the sprite url to the raw content README.md
  3. Commit and push the new content
function updateReadme(spriteMarkdown){
    getReadme().then(({ data }) => {
        const rawContent = Buffer.from(data.content, data.encoding).toString(); // step 1
        const startIndex = rawContent.indexOf("<!--Pokemon Sprite-->")
        const updatedContent = `${startIndex === -1 ? rawContent : rawContent.slice(0, startIndex)}\n${spriteMarkdown}` // step 2

        octocore_client.request(`PUT /repos/${repo_owner}/${repo_name}/contents/README.md`, {
            message: commit_message,
            content: Buffer.from(updatedContent, "utf-8").toString(data.encoding),
            path: "README.md",
            sha: data.sha,
        }) // step 3
    })
}
Enter fullscreen mode Exit fullscreen mode

A couple of things to note about the function above:

  • const StartIndex is a custom string that needs to be defined because that will tell the function where in the README.md to append our new content (the sprite url). Otherwise it will overwrite the entire file.
  • When you get the file contents you decode from base64 to utf-8, and when you write the contents back you need to encode it in base64
  • When editing existing file contents in github, the file sha is necessary. Otherwise Github will throw an error (rightfully so because it needs to know the exact version of the file to edit)

Finally you can call these functions inside our Fetch call:

fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`)
        .then((response) => response.json())
        .then((data) => { 
            const spriteUrl = data.sprites.front_default;
            const spriteMarkdown = `<!--Pokemon Sprite-->\n![image](${spriteUrl})`;

            updateReadme(spriteMarkdown);
        });
Enter fullscreen mode Exit fullscreen mode

Now you're all done with the action code. Click here to view the whole file.

Adding the Action YAML

The Action YAML file declares the inputs (that we defined in our javascript file earlier) and build engine (in this case, node16) for the action code. I'll spare you another long code block and just link an example here.

Here's some more documentation on Action YAMLs.

Testing End to End

Build

Before you deploy to the main branch, you need to test the action end to end to see if it works! (Actually, you should have been testing all throughout).

Building the nodejs code requires all our dependencies. But committing the node_modules folder in Github is generally agreed to be a bad practice.

Therefore, you will use a tool called ncc from vercel, which you installed in the beginning.

In your root directory, enter the following:

ncc build index.js --license licenses.txt
Enter fullscreen mode Exit fullscreen mode

This will create a single gcc-like javascript file that the github runner's node16 build can run. More on ncc here.

Once this is complete, you need to edit the actions.yaml so so that the action knows to look inside the dist folder that contains the compiled javascript code.

runs:
  using: 'node16'
  main: 'dist/index.js'
Enter fullscreen mode Exit fullscreen mode

Setting up the "client"

In the repository you want to add the pokemon sprite to, you'll need to setup a workflow file and also add the markdown index to your README.md, which will let the action know where to inject the sprite url.

Your workflow file will be located at <project-root>/.github/workflows/workflow-file.yaml, and can look something like the following:

name: Poke Readme

on:
  workflow_dispatch:
    inputs:
      pokemon:
        description: 'Pokemon Name'
        required: true
        default: 'pikachu'

jobs:
  add-poke-sprite:
    name: Add Pokemon Sprite to repo README
    runs-on: ubuntu-latest
    steps:
      - uses: ibrahiem96/poke-readme-action@main
        with:
          pokemon: ${{ inputs.pokemon }}

Enter fullscreen mode Exit fullscreen mode

And here's an example of how to inject the index string in your README.md

content
...
<!--Pokemon Sprite-->
...
content
Enter fullscreen mode Exit fullscreen mode

To trigger the action, you need to go to your Actions Tab in the github repository and manually dispatch the workflow. You can run it with the default pikachu sprite, or you can check out the list of all pokemon names supported by the API

Image description

Once this succeeds, you can see the sprite on your README.md.

This is my favorite pokemon, Salamence

Image description

Cleanup and Deploy

That wraps up the project! What's left is to clean up the code, deploy, maybe version the project, and think about what enhancements or features to add in the future.

See you next time, gotta catch 'em all !

Top comments (0)