πThis is a long article. For those who'd prefer to look at code, rather than read a longer article you can browse the code here.
In my time at Hugo Health, when I am not working on data operations and scaling, I have been helping to facilitate a major refactor of our systems. A huge part of this is the way that we manage our packages.
Admittedly, the way we used to do things was a bit of a mess. Most everyone there would probably agree with that sentiment. So, we decided to change things up. We had a few things we wanted to accomplish with these changes.
We wanted human readable descriptions of the changes made to each project, but we didn't want too add to much extra effort into commiting these changes.
We wanted to add Semantic Versioning. Semantic Versioning allows you to know at a glance what sort of changes the update contains.
Patch - Backwards compatible bug fix
Minor - Backwards compatible functionality
Major - Incompatible changes
We decided that the best way to go about doing this would be to use @changesets/cli to automatically version and generate a changelog for each of our packages.
We had one issue with moving forward with this route -- there was no readily available integrations with Azure DevOps.
After messing around with the cli, it seemed as if this was a great, relatively easy way of having our devs declare their changes prior to publishing the package. Why? It's pretty simple to use.
After making your changes, run the command yarn changeset, and enter what kind of changes you are making.
Generally, I like to run this command for every "feature" included in the PR (though, we generally don't include more than one feature per pull request as a general practice).
When you're completely done with your changes, just before opening the pull request, run yarn changeset version.
This command will automatically update the version of the package, and generate the corresponding changelog. It will bump the package to the minimum version required within all included changeset files.
Finally, using the command yarn changeset publish will publish the changes to the registry.
When doing research on how to create this bot, I came across an open issue in the @changesets/changesets repository from March 11, 2020 with activity as recent as 4 days ago (January 12, 2023 at the time of writing this).
It seemed as if there was a geniune interest in accomplishing this, especially because it was already possible using Github Actions.
We got to thinking that this would be a pretty good use case for Azure Functions. Functions is a serverless solution, similar to Vercel Serverless Functions and AWS Lambda. These three services can accomplish roughly the same thing, but we went with Azure since we were already in their ecosystem. There was already existing functionality using Azure Pipelines to post to an external service on Pull Request Creation or Update.
To brainstorm, we decided to map out the ideal steps to our pipeline.
Our bot will be responsible for executing the "Contains Changeset" and "Version and add to PR" steps of this flowchart.
We have existing pull request checks that require that all comments be resolved prior to being able to merge. So the easiest way, by default, to implement this functionality would be to have our bot create a comment that blocks the merge and updates the comment when it finds the missing changeset.
Changeset also allows an empty changeset for changes that aren't meant to be "released". For example commits containing developer tooling, changes to a tsconfig, etc.
For those unfamiliar with Azure Functions, getting it up and running on your machine is fairly simple, especially if you use Visual Studio Code.
I'll run over the instructions for getting started. I am using MacOS, but the only step this effects is using the homebrew package manager to install Azure Functions Core Tools. For other operating systems, check out Azure's documentation.
Use the install button here to install the extension, or type "Azure Functions" into the extension search bar in visual studio code and install from there.
Select the Functions extension from the sidebar in VSCode, and then select the lightning bolt icon to create a new function.
It will ask you where you want to create the new function (default is the open directory in VSCode) and then what language you'd like to use. For this tutorial, we are using typescript.
Finally, it will ask what kind of trigger you want to use. Select "HTTP Trigger", since it will be triggered using a POST request after a PR is created or updated.
Then you will name, and select the authorization level of the function (I chose admin). If all goes according to plan, you should have a resulting directory that looks like this.
At my job, we use yarn instead of NPM. These next few steps are optional, but if you'd like to do the same these changes will incorporate yarn as the package manager.
First, remove the node_modules folder, and generate a yarn.lock from the package-lock.json.
Then, you will want to modify .vscode/tasks.json to remove all npm references.
Before we start developing, we will want to set up our development "server" so that we can easily test what we are writing. I do this using two steps.
Open up a terminal, navigate to the directory you created, and run
this command will run tsc in watch mode, so it will rebuild automatically when you make changes.
Then, open the command pallete in VSCode and execute the "Tasks: Run Task" command.
It will then ask you which task to run, so select func: host start. A terminal window will open showing the output of that command. You're ready to start writing code now!
To begin working with azure-devops-node-api, you will first need to add it as a dependency.
The only thing we will need in order to grant it access to your projects is a personal access token, which you can create by following these instructions.
Once you have your token, create a .env file in the root directory of the project with the following
replacing <YOUR_TOKEN> with the token you generated. We will start off by making a utility file to hold some functions that make working with the API code a little bit easier.
Using azure-devops-node-api isn't too complex, but does require a minor bit of set up. Namely, we need to define an authorization handler and use the token we generated.
We will start by defining a method that will get our environment variables for us.
Basically, this will prevent our code from running if it can't find the environment variable with the name passed into the method.
Next, we will define a method called getApi. This will return the webApi from azure-devops-node-api.
This code does a few things.
Gets the personal access token from the environment
Defines the auth handler using the personal access token
Creates a new WebApi instance
Connects the new WebApi instance
Finally, we will just wrap a handler around this method so that we can either pass in a baseUrl or use one from the environment.
The next part of this tasks is to setup the trigger for the function. If you open up index.ts in the function folder, you will see the following
This method defines the trigger that will be hit once a PR is created or modified. Azure will POST to the URL of this function, with the following information.
This information gives us everything we need to know in order to modify the PR and check for changesets. We will start out with parsing the relevant information from the request body and initializing the connection to Azure's WebApi.
Then, get the GitApi and all of the relevant PR information.
Let's use a class to represent all the bot operations we wish to perform. Create a new file in the function directory, called changebot.ts and create a new class. Our constructor for this class will take the parameters api to interact with Azure's GitApi and pr to have the relevant pull request information.
We will then define a public method, called check which will check for the changeset files, and push any CHANGELOG.md and package.json changes.
The return type, ChangebotResult returns a list of threads created by the bot, and a boolean flag true if it passes, false otherwise. From here we can move on to checking for changeset files.
βοΈ I'd like to rewrite this portion of code to use actual git operations rather than interfacing with Azure's API because I already have to clone the repository to generate the changelog entry anyways.
Create a private method called hasChangeset. You won't need any parameters to perform this, because we already have everything we need stored as members of the class.
Generally, we will want to do the following within this method.
Retrieve an updated list of changed files from the PR.
Filter those files for unique changeset files
Create the comment on the PR that displays the check status.
We can update the list of changed files using the GitApi by doing the following
and updating the git interface imports to
We can then use this to get a list of all changed files and search it for changesets.
Now that we've determined if the PR has at least one changeset file, we will create a comment thread detailing its status.
You will also need to update the imports in changebot.ts to the following:
You may have noticed in the above snippet that I called this.threads.setThreadStatus and this.threads.upsertThread. To manage threads, I created a new file called thread-manager.ts with a class called ThreadManager. This class will also use Azure's GitApi to manage different threads on the PR.
ThreadManager will be responsible for:
Upserting threads (updating threads if they exist and creating new ones if they don't)
Retrieving threads matching certain predicates
Finding previous comments
Posting comments
Because this article is already lengthy and interacting with threads in Azure is well documented, I won't delve into details, but you can view the source code here.
Create a new method in ChangeBot called generateChangelog.
This code gets mildly complex because we need to interface with git. To accomplish this, I wrote another manager class called GitManager. Again, for sake of time (and because we care more about programmatically generating changelogs/checking for changesets) you can check that file out in the repo.
First, start out by adding the dependencies for interfacing with changesets.
You'll want to create a new utility method that gets an entry from the Changelog. This code is lifted directly from the Changeset Github Action.
Using this code, we can do the following in our changelog generation method:
From there, you can create a new thread in the same way that we did for the changeset check, and return the result of the function.