Develop your own GitHub README game!

Develop your own GitHub README game!

This article will give you a very quick introduction to develop your own README games!

ยท

10 min read

Featured on Hashnode

Introduction

I've begun to lately notice that everyone prefers to look at people's GitHub profiles over their portfolio websites to accurately judge a person's skill level.

I had to do something that would make my GitHub profile stand out - there are so many articles online helping you do just that with creative stats and badges but I wanted to do something that would stand out more!

This led me to discover an amazing profile here that made a community chess game on their README!

I was extremely fascinated and thought it would be interesting to make my own game and share the learnings with others!

How does it work?

So, the idea is actually pretty simple. It completely revolves around GitHub Actions.

Essentially, a user creates an issue with a particular title which causes GitHub (via GitHub Actions) to run a script that you've written!

This script will modify your readme and push back the new changes, making it "interactive".

Let's take a closer look at making our very own Rock-Paper-Scissor game using Python!

You can test it out here!

Developing our game

Note: This is assuming that you have already created a repository for your project.

Adding our dependencies

We'll be using dominate to generate HTML using Python for our readme and pygithub to make calls to the GitHub API to get information about the issue and update the readme.

Run the following to install our dependencies.

pip install dominate pygithub

Diving into the Python code

So, for this section, I'll explain the functions one by one and try to cover most of the statements.

main.py

import random
import os
from github import Github
from dominate.tags import *


class RPS:
    def __init__(self, token, issueNumber, repo):
        self.token = token
        self.repo = Github(token).get_repo(repo)
        self.issue = self.repo.get_issue(issueNumber)
        self.moves = ['rock', 'paper', 'scissor']
        self.filePath = 'README.md'

    def playMove(self):
        fileData = self.fetchFileFromRepo(self.filePath)
        userName = self.issue.user.login
        move = self.issue.title.lower().split('|')
        if (len(move) > 1) and move[1] in self.moves:
            move = move[1]
            self.computerMove = self.computerMove()
            if move in self.moves:
                result = self.didUserWin(move)
                newFileData = self.genFileData(userName, result)
                action = self.getEmoji(move)
                if result == True:
                    self.addComment('Congratulations! You won! :tada:')
                    self.writeToRepo(self.filePath, f"@{userName} won with {action}", newFileData, fileData.sha)
                elif result == None:
                    self.addComment('Oops! This was a draw! :eyes:')
                    self.writeToRepo(self.filePath, f"@{userName} played {action}", newFileData, fileData.sha)
                elif result == False:
                    action = self.getEmoji(self.computerMove)
                    self.addComment(
                        f'Uh-Oh! You lost! :eyes:\n Computer played {self.computerMove}')
                    self.writeToRepo(self.filePath, f":robot: won with {action}", newFileData, fileData.sha)
        else:
            self.addComment('You played an invalid move! :eyes:')
        self.issue.edit(state="closed")

    def getEmoji(self, move):
        if (move == 'rock'):
            return":punch:"
        elif (move == 'paper'):
            return":hand:"
        else:
            return ":scissors:"

    def fetchFileFromRepo(self, filepath):
        return self.repo.get_contents(filepath)

    def writeToRepo(self, filepath, message, content, sha):
        self.repo.update_file(filepath, message, content, sha)

    def addComment(self, message):
        self.issue.create_comment(message)

    def computerMove(self):
        return random.choice(self.moves)

    def didUserWin(self, userMove):
        if ((userMove == 'rock' and self.computerMove == 'scissor') or (userMove == 'scissor' and self.computerMove == 'paper') or (userMove == 'paper' and self.computerMove == 'rock')):
            return True
        elif (userMove == self.computerMove):
            return None
        else:
            return False

    def genFileData(self, userName, result):
        outer = div()
        repo = self.repo.full_name
        with outer:
            h1("Rock Paper Scissors Game!")
            p("Click on one of the below actions to play your move:")
            h3(a(":punch:", href=f'https://github.com/{repo}/issues/new?title=rps|rock'), a(":hand:", href=f'https://github.com/{repo}/issues/new?title=rps|paper'), a(":scissors:", href=f'https://github.com/{repo}/issues/new?title=rps|scissor'))
            if result == True:
                h4(f"Previous winner was @{userName} :tada:")
            elif result == False:
                h4(f"Previous winner was computer :robot:")
            else:
                h4(f"Previous game was a draw :eyes:")
        return outer.render()


if __name__ == '__main__':
    run = RPS(os.environ['TOKEN'], int(
        os.environ['ISSUE_NUMBER']), os.environ['REPO'])
    run.playMove()

So, as you can see it's pretty simple. Let's take a closer look to understand the different calls and RPS(Rock Paper Scissor) logic.

Initialize function

    def __init__(self, token, issueNumber, repo):
        self.token = token
        self.repo = Github(token).get_repo(repo)
        self.issue = self.repo.get_issue(issueNumber)
        self.moves = ['rock', 'paper', 'scissor']
        self.filePath = 'README.md'

This is the first function that's run when the RPS object is created i.e, this is the constructor. We are initializing the object with the token, issue number, and repository name which we will be setting as environment variables using GitHub Actions.

self.repo is the Repository object from pygithub. We need this to fetch the particular issue from the repository using the issue number.

self.issue is the Issue object from pygithub. We need this to get the issue title, add comments and close the issue to indicate it was executed.

self.moves is essentially a list of all the valid moves. You can get creative and add more moves if you'd like to!

self.filePath is essentially the path of your markdown file. Since mine is in the root directory, I'll leave it as is.

Miscellaneous functions

Since there are a bunch of one-line functions, I'll group them together and give a brief description.

    def fetchFileFromRepo(self, filepath):
        return self.repo.get_contents(filepath)

    def writeToRepo(self, filepath, message, content, sha):
        self.repo.update_file(filepath, message, content, sha)

    def addComment(self, message):
        self.issue.create_comment(message)

    def computerMove(self):
        return random.choice(self.moves)

    def getEmoji(self, move):
        if (move == 'rock'):
            return":punch:"
        elif (move == 'paper'):
            return":hand:"
        else:
            return ":scissors:"

fetchFileFromRepo is used to get a particular file from your repo. It takes the file path as an argument and returns the File object by making a call to the API using the get_contents(filepath) function of the Repository object in self.repo

This File object has a bunch of attributes, we are only interested in the content and sha of the file.

writeToRepo is used to commit and push a single file to your repository. Since we're only working with your readme file, we shall use this. It takes in the file path, commit message, file content, and sha (which we received from the File object from the previous function. We call the update_file function of Repository object to update our readme with the new content.

addComment is used to add a comment to the issue. We use this to indicate the result of the move. It only takes in the comment (message) as an argument and uses the create_comment function of Repository object to add a comment to our current issue (which we fetched using the issue number)

computerMove is a very simple function that returns a random move from the list of moves that we defined in self.moves

getEmoji is used to return the emoji corresponding the move that's been sent to it as a parameter.

Function to check if user won

    def didUserWin(self, userMove):
        if ((userMove == 'rock' and self.computerMove == 'scissor') or (userMove == 'scissor' and self.computerMove == 'paper') or (userMove == 'paper' and self.computerMove == 'rock')):
            return True
        elif (userMove == self.computerMove):
            return None
        else:
            return False

So this function is very straightforward. It calculates if the user won, lost or if the game was a draw.

We first check all the combinations of the user's move with the move played by the computer.

  • We return True if user won
  • We return None if both the choices were the same (draw)
  • We return False if user lost

Function to generate HTML

    def genFileData(self, userName, result):
        outer = div()
        repo = self.repo.full_name
        with outer:
            h1("Rock Paper Scissors Game!")
            p("Click on one of the below actions to play your move:")
            h3(a(":punch:", href=f'https://github.com/{repo}/issues/new?title=rps|rock'), a(":hand:", href=f'https://github.com/{repo}/issues/new?title=rps|paper'), a(":scissors:", href=f'https://github.com/{repo}/issues/new?title=rps|scissor'))
            if result == True:
                h4(f"Previous winner was @{userName} :tada:")
            elif result == False:
                h4(f"Previous winner was computer :robot:")
            else:
                h4(f"Previous game was a draw :eyes:")
        return outer.render()

This function shows the versatility of dominate module which allows you to generate HTML using Python. It's very easy to pick up and makes the code so much more readable than hardcoding HTML for the readme file.

If you aren't aware of this yet, we can use HTML instead of Markdown for the readme file to generate the different elements (table, headers, paragraphs, etc).

To generate an HTML element, we can just call the function for it, eg: h1("Rock Paper Scissors Game!") translates to <h1> Rock Paper Scissors Game! </h1>

We can also pass attributes for the tag within the function itself. Dominate allows you to nest these functions to make a structure quickly.

We use with outer: to basically generate a structure with <div> tag as the parent tag (since outer is nothing but div function being called).

Any element being called within this block will be considered a direct child of the tag (in this case outer)

We've added three emojis for rock, paper, and scissors and used an anchor tag to directly redirect to the Create a New Issue page with the title pre-populated.

We're also updating the content according to the current game's result. outer.render() will render the HTML that we've added

Main function to play the move

    def playMove(self):
        fileData = self.fetchFileFromRepo(self.filePath)
        userName = self.issue.user.login
        move = self.issue.title.lower().split('|')
        if (len(move) > 1) and move[1] in self.moves:
            move = move[1]
            self.computerMove = self.computerMove()
            if move in self.moves:
                result = self.didUserWin(move)
                newFileData = self.genFileData(userName, result)
                action = self.getEmoji(move)
                if result == True:
                    self.addComment('Congratulations! You won! :tada:')
                    self.writeToRepo(self.filePath, f"@{userName} won with {action}", newFileData, fileData.sha)
                elif result == None:
                    self.addComment('Oops! This was a draw! :eyes:')
                    self.writeToRepo(self.filePath, f"@{userName} played {action}", newFileData, fileData.sha)
                elif result == False:
                    action = self.getEmoji(self.computerMove)
                    self.addComment(
                        f'Uh-Oh! You lost! :eyes:\n Computer played {self.computerMove}')
                    self.writeToRepo(self.filePath, f":robot: won with {action}", newFileData, fileData.sha)
        else:
            self.addComment('You played an invalid move! :eyes:')
        self.issue.edit(state="closed")

Okay, this function basically calls the previous functions and puts them all together.

fileData contains the current readme content. We mainly need this for the sha value so we can update it with the new result.

userName is the GitHub username of the user who created the issue.

move basically splits the title (which is rps|move) and gets the move from the split list.

We perform some basic validation to make sure the issue created is not spam/invalid.

Depending on the result that we get from didUserWin() (True, False, None) we add a comment and write the new file data to the repo using the writeToRepo function.

Finally, we close the issue so that we can keep track of all the executed issues.

Setting up our workflow for GitHub Actions

build.yml

name: RPS

on:
  issues:
    types: [opened]

jobs:
  rps-main:
    runs-on: ubuntu-latest
    if: startsWith(github.event.issue.title, 'rps|')
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Python
        uses: actions/setup-python@v2
        with:
          python-version: "3.10"
      - uses: actions/cache@v2
        name: Configure pip caching
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      - name: Install Python dependencies
        run: |
          python -m pip install -r requirements.txt
      - name: Running Main File
        env:
          REPO: ${{github.repository}}
          ISSUE_NUMBER: ${{github.event.issue.number}}
          TOKEN: ${{github.token}}
        run: python main.py

Okay! I know this looks overwhelming, but it's SUPER easy. Let me break it down.

First of all, this is a YAML file, so it uses YAML syntax.

name is the name of the Workflow which shows up in your repository under Actions

on basically is the trigger that causes your workflow to run. Workflow, in simple terms, is Ubuntu being set up and this script is run.

Since we want our script to run when a user makes an issue, we have used that.

jobs are the steps that have to be run (which usually ends with your script being run)

Everything from setting up the OS to executing the script comes under this.

rps-main is the name of the workflow, no real significance, will just show up under Actions.

runs-on takes ubuntu-latest as we want to run on Ubuntu We have used a conditional statement to ensure that the issue only runs if the issue title starts with rps|

We get various attributes like issue title, issue number, and repository name from the github context object which has these properties.

If the condition is fulfilled, we have a series of steps that essentially uses GitHub Actions (which we can get from the Marketplace) to automate some commonly used tasks like setting up Python and pip or checking out to a branch)

run With | allows for multiple line commands. We're using it to execute our main script, main.py

We're using env to set our environment variables which we get from the github context object.

The token from this object will allow the github-actions bot to perform the commits and pushes.

What next?

So, this article was just a very quick introduction to the code and workflow syntax to get you started.

The obvious next step would be to add a database / JSON file so you can keep track of moves and results played by different users.

You can check out a more elaborate version of a game called Mastermind on my repo here.

Conclusion

GitHub Actions is definitely one of the best things that I've learned this year. Automating tests within GitHub itself (and free for open-source projects) is literally amazing!

I'm looking forward to seeing people come up with more creative uses for GitHub Actions (besides using it as a CI/CD tool)!

ย