Hi everyone,
Recently I've published a GitHub app. Darkest-PR is a bot for responding to the actions occurring in your repository by commenting them with quotes from the Darkest Dungeon game. The goal was to make development more fun and enjoyable thanks to the narrations by the Darkest-PR.
In this post series, I'm going to explain how I built my first GitHub app, what was the important lessons that I've learnt, what secrets I've discovered and how you can do the same!
These series will be separated into episodes/parts so you can follow the journey easily.
Episodes:
- Beginning and architecture (you're here!)
- Unit and feature testing, mocking & spying
- Code coverage reports
- Deployment and CI/CD
- Publishing and final remarks
Don't forget to check the repository if you'd like to follow along!
Prologue
Prior to this project I haven't practiced or used Typescript extensively. I knew how it operated and the differences of it with JS but never used it properly in a project. I've primarily used vanilla JS for years and had no problem with run-time debugging, type-safety or type-hinting. Therefor never really had a craving for Typescript.
After deciding to build a GitHub app/bot, and discovering the Probot framework, during the installation I was asked whether to install framework for JS or Typescript. Since I wanted to experiment with it for a long while, I said hell why not and choose Typescript. I also wanted to experiment on the OOP and design pattern capabilities of the TS, to see it firsthand. So that's how and why I choose TS for this project.
Goal
The goal was to develop a bot. I wanted this bot to respond with quotes specifically from Darkest Dungeon game.
I've always enjoyed roguelike, dungeon-crawler and RPG games. Among all those games many, Darkest Dungeon has a special place in my heart. In no such game I've encountered such captivating, strong, profound and invested monologues. Both the audio and the narration scripts are spectacular. It is so Shakespearean. Another reason I keep it so dear is that it's stress, hope, despair, loss mechanics are unique. It really makes you connected with the game, as if you're feeling the actual moments your characters are going through, the ambiance is riveting. The first time you get party-wiped, you learn the true meaning of desperation and the setting of the game. It's like Dark Souls of dungeon crawlers.
Fun fact: I unconsciously memorized 90% of all the quotes from Darkest Dungeon.
In my career, I've reviewed thousands upon thousands pull requests. Countless issues, bugs tackled. Worked with my fellow teammates to undertake many challenging features. Each of these feature a different setting of emotions; some of them are definitive struggles, some a gentle breeze, some terrorizing nightmares, some are well-deserved relief after completing them.
Then I realized, each development is very similar to Darkest Dungeon runs. Your team is your party, your environment is your location, your task/goal is your adversary. So it would be apt to narrate the development like so.
And for this reason, Darkest-PR has come to life. To make development process more interactive, more fun, more story-like, an epic tale.
Humble beginnings
After performing some research on how a GitHub app operates, without long I stumbled upon Probot. Probot is a framework for building GitHub applications using TS or JS. It has extensive documentation and handles all the nifty details which you might find daunting at first like: authentication, API keys, API security etc. After seeing how easy it would be using Probot, I decided to use it.
I've initialized the project with npx create-probot-app my-first-app
command and filled in the questionnaire by the CLI:
Let's create a Probot app!
? App name: Darkest-PR
? Description of app: A 'Hello World' GitHub App built with Probot.
? Author's full name: John Doe
? Author's email address: john@doe.com
? GitHub user or org name: skywarth
? Repository name: Darkest-PR
? Which template would you like to use? (Use arrow keys)
basic-js
❯ basic-ts (use this one for TypeScript support)
checks-js
git-data-js
deploy-js
Finished scaffolding files!
Installing dependencies. This may take a few minutes...
Successfully created my-first-app.
Begin using your app with:
cd my-first-app
npm start
View your app's README for more usage instructions.
Visit the Probot docs:
https://probot.github.io/docs/
Get help from the community:
https://probot.github.io/community/
Enjoy building your Probot app!
And there it was, the first step.
Architecture
Being a software engineer, I have a strong appetite for designing the architecture of software systems. What are the requirements? What are the limitations? What do we strive for? Anyone who's ever done systems designs can tell what I'm talking about here. Functional and non-functional requirements of the system is to be determined. Later I've moved on to choose the infrastructure and establish data flow. This is high level design. Thanks to Probot, most of these are already established and decided for so there is not much of a high level design to be made.
So I moved on to low-level-design, the LLD. My first priority here was to determine the technologies, platforms and most importantly the design patterns I'll be using. It is important to determine these beforehand, before you write a single line of code because they drastically change the course of the project. These are the patterns I've elected to go along with:
Repository pattern
This is for the abstraction of data. Since 'quotes' in the app are constant and technically a data source, I decided to apply the principle of abstraction because even though currently it is loaded from a JSON file, I might decide to move them to a MySQL database, or a CouchDB, or a NoSQL database, or maybe even load them straight from variables in code. Who knows? To be prepared for these kind of challenges, and to make these transitions smooth you need to apply repository pattern. But keep in mind I didn't apply it just because of a 'if', I applied it because it is likely to change. Don't use patterns blindly or out of mere possibility, there should be a likelihood.
Strategy pattern
According to my requirement analysis, the app needs to respond to various events occurring in the user's GitHub repository. Such as PR opened, PR closed, Issue assignment, assignment removal, approval etc. Dozen events to begin with. And there could be more to come in the future. And to each of these events the app has to respond with a comment, that is the bottom line of it. Each event features a different data payload received from the webhook. Moreover, each of these events bear a different contextual emotion matrix so a fitting quote can be made for it. Essentially, each is a different strategy. Hence the strategy pattern. It is a good use-case for using strategy pattern. So I developed a structure and various abstraction to accommodate the need for various event handlers and in the end it fit perfectly for the mechanism. It allowed for a clean architecture and easy time debugging, changes were smooth if required.
Singleton pattern
I gotta admit this was a bit of a blunder at first. Coming from other backend frameworks; life-cycle is different in NodeJS. NodeJS features a persistent state application, opposed to other backend frameworks like Laravel which has request based life-cycle. I didn't knew beforehand so it became a issue at first. I wanted to make certain components and modules singleton to prevent redundant fetching and loading operations, to reduce redundancy. For modules such as Config and QuoteRepository this makes sense, because why would you load config or quotes multiple times throughout the lifecycle if they are not subject to change? After this discovery of persistent state life-cycle, I altered singleton logic to accommodate the requirements. It is not a crucial pattern to have but I have a habit of using it when it is possible for optimization purposes. Beware that if you're not proficient with singleton pattern or the life-cycle of the infrastructure you're using, it is very likely you'll run into errors and bugs.
Facade pattern
A classic design pattern. My quote fetching and selection criteria are quite vast in the application, there are many different aspects to consider and evaluate, ordering and selection to be made based on the contextual emotion matrix of the situation. To abstract this level of complexity from higher level modules, I applied facade pattern to simplify the acquisition of quotes by the strategy patterns. QuoteFacade is consumed by the strategy pattern implementations.
export class QuoteFacade{
getQuote(actionContext:ActionContextDTO):Quote|undefined{
//Case 1: if any other parameter along with slugs are provided: slugs and other filters will be applied separately and later be merged.
//Case 2: if only slug param is provided, only will filter only by slugs.
let quotes:QuoteCollection;
if(actionContext.hasOnlyQuoteSlugs
){
quotes=new QuoteCollection([]);
}else{
quotes=QuoteFacade.#quoteRepository.index();
}
if(actionContext.hasSentiment){
//@ts-ignore
quotes.filterBySentiment(actionContext.sentiment);
}
quotes.orderByCumulativeScoreDesc(actionContext.emotionMatrix,actionContext.tags);
quotes.selectCandidates();
if(actionContext.hasQuoteSlugs){//Damn this is ugly as hell
let quotesBySlug=QuoteFacade.#quoteRepository.index().filterBySlugs(actionContext.quoteSlugs??[]);
quotes.merge(quotesBySlug);
}
return quotes.shuffle().first();
}
getQuoteBySlug(slug:string):Quote|undefined{
return QuoteFacade.#quoteRepository.find(slug);//Should we use the Repo's find or collection's find?
}
}
Chain of Responsibility pattern
This one is really debatable and forced I'm aware. I wanted to implemented it because I've been seeking an opportunity to use it for a long time. Chain of responsibility is like a middleware structure, it passes or stops at the chains depending on assertions on each chain. My app configuration was dependent on both the BotConfig and RepositoryConfig of the user, I had to check and assert these whenever an action was due to happen, to simplify this I used chain-of-responsibility pattern.
const botConfigSubsHandler=new EventSubscriptionHandler(BotConfig.getInstance());
const repoConfigSubsHandler=new EventSubscriptionHandler(repoConfig);
botConfigSubsHandler.nextHandler=repoConfigSubsHandler;
if (!botConfigSubsHandler.handle(this.getEventName())){
return;
}
Factory pattern
Even though we've abstracted the complexity by implementing Facade pattern for quotes, as QuoteFacade. This made strategy pattern implementations far simpler and cleaner, there wasn't anything in them that didn't belong there. But there was a small problem. Depending on the config, user may opt to see debug output or toggle emoji support. It is true we can easily obtain these settings from Config's but certain configs that are defined in the user's repository is bound to a event payload so we can fetch the repository config. This indicated a problem, QuoteFacade on it's own cannot do it because it doesn't have event payload context. And if we send it to the QuoteFacade by the strategies it would break SOLID principles and make testing so difficult. I resolved this by moving this requirement for config to a CommentFactory. CommentFactory takes a QuoteFacade instance and values for configs. This way the dependence between QuoteFacade, CommentFactory and Configs are broken, high achieved. Strategy pattern implementations were injected CommentFactory instances by the abstract strategy class and it was clean now.
const repositoryConfigPartial=await RepositoryConfig.readConfigFromRepository(this.ghContext as any as Context);
const repoConfig=new RepositoryConfig(repositoryConfigPartial);
const commentFactory=new CommentFactory(QuoteFacade.getInstance(),repoConfig.debug_mode,repoConfig.emojis);
const comment:Comment|null=await this.execute(commentFactory);//relaying to the child classes of the abstact strategy, each is responsible for the implementation of execute() method.
export class CommentFactory {
#quoteFacade:QuoteFacade;
#debugMode: boolean;
#emojis: boolean;
constructor(quoteFacade:QuoteFacade,debugMode: boolean, emojis: boolean) {
this.#quoteFacade=quoteFacade;
this.#debugMode = debugMode;
this.#emojis = emojis;
}
get quoteFacade(): QuoteFacade {
return this.#quoteFacade;
}
get debugMode(): boolean {
return this.#debugMode;
}
get emojis(): boolean {
return this.#emojis;
}
create(
caseSlug: CaseSlugs.Types,
actionContext: ActionContextDTO,
replyToContext: ReplyContext | null = null,
warnings: Array<string> = []
): Comment | null {
const quote: Quote | undefined = this.quoteFacade.getQuote(actionContext);
if (!quote) {
return null;
}
return new Comment(
quote,
caseSlug,
actionContext,
this.debugMode,
this.emojis,
replyToContext,
warnings
);
}
}
Stay tuned for part #2 where we will be covering the episode of unit and feature testing. Thank you for reading!
Top comments (2)
Published the third episode, it's about code coverage. Go ahead and check it out: dev.to/skywarth/how-i-built-my-fir...
Added the second episode of the development journey: go ahead and check the part #2