Trying to integrate your custom processes with GitHub doesn’t have to be challenging. One of the easiest ways to implement custom functionality is to create a GitHub App using Probot. The challenge comes if you need to implement a listener for a new webhook event or invoke a new API that Probot hasn’t exposed. The good news – it’s not too difficult. As an example, we’ll explore implementing both the event handling and REST method calls for some of the new Dependabot endpoints.
What are GitHub Apps?
If you’re not familiar with them, GitHub Apps:
- Can listen for web hook events from your organizations and repositories. They can respond to alerts, commits, PRs, membership changes, and more.
- Can create tokens and use those to invoke GitHub REST APIs. Instead of a Personal Access Token, they can request their own tokens dynamically.
- Have their own rate limits – up to 15,000 requests per hour for enterprises.
- Have their own identity, including per-repo permissions. This can be a great way to create a “service principal” for invoking GitHub APIs. My colleague Josh covers that really well in this article.
What is Probot?
Building an app is a straightforward process that involves implementing a web hook, responding to the events, and requesting tokens to call the REST APIs when required. Even though it is all implemented as RESTful APIs, it can still take some time to implement everything. That’s where Probot comes in. Probot creates a framework for writing a complete App in JavaScript or TypeScript. Full details for generating the your initial code is available on the website.
To use the generated code, you simply register handlers for the
events that interest you and add the webhook event and
required permissions to the generated app.yml
. For example, to listen for all code scanning alerts:
1app.on("code_scanning_alert"), async(context) => {
2 // Your logic here
3});
If you want to only listen for specific actions associated with the event, you can register for that as well. Of course, you can delegate any of these calls to other functions.
1app.on("code_scanning_alert.closed_by_user"), codeScanningAlertDismissed);
The context object is strong-typed if you’re using TypeScript. In this case, it’s a Context<"code_scanning_alert">
. Having a strong-typed event makes it very easy to write the code with type checking support. If you’re using an IDE, it also enables code-completion for querying the payload. The context also exposes a method for logging and an
Octokit instance. Octokit provides strong-typed access to the GitHub REST APIs and automatically requests an appropriate token to authenticate with the GitHub App’s identity.
An example of using these features:
1function codeScanningAlertDismissed(context: Context<"code_scanning_alert">) {
2 context.log.info("Code scanning alert event received.");
3
4 const organization = context.payload.repository.owner.login;
5 const user = context.payload.alert.dismissed_by?.login;
6 const repo = context.payload.repository.name;
7 const alert_number = context.payload.alert.number;
8
9 if (isAllowed(user)){
10 await context.octokit.codesScanning.updateAlert({
11 owner,
12 repo,
13 alert_number,
14 state: "open"
15 });
16 }
So what’s the issue?
Probot offers strong-typing using TypeScript. This provides guidance as you attempt to implement your application. For example, I recently wanted to add support for a newer beta webhook and API for handling
dependabot_alert
s. Like all open source projects, Probot relies on volunteers to keep it up to date. In some cases, it takes time for their packages to reflect updates to the APIs. In this case, Probot has not added support for the webhook event or the new Dependabot REST APIs. To use these features, you have to implement them yourself.
The manifest
The application manifest lists all of the event types and permissions our application requires. Unfortunately, the current version of the code behind
GitHub’s Manifest Flow is not yet aware of the dependabot_alert
event. As a result, it throws an error early in the process. To avoid this, simply omit the dependabot_alert
from the manifest. When the GitHub App is deployed, you’ll need to manually enable the event:
The app.yml
file is primarily used to support the manifest flow, so there’s no problem leaving that event out. When GitHub adds support for the event, then we can add it back the event to the manifest. Dependabot doesn’t validate whether or not it can support the event.
The event listener
Implementing the event listener is a bit more challenging. Normally, it’s simple:
1app.on("dependabot_alert.dismissed", dependabotAlertDismissed);
However, TypeScript will immediately provide an error message informing you that the event type is not supported. Because Probot is missing that API, the type checks in TypeScript make you aware there is a problem. One way to handle that is to use the onAny
hook. Because all events flow through this handler, it provides an easy way to implement that call.
The context provided has a minimal set of properties. Although every context has a payload and an action, the provided context does not expose those. To work around that, either change the type to any
or define a type/interface that exposes the required properties.
Using any
:
1app.onAny(async(context) => {
2 const ctx = context as any;
3 const eventName = `${context.name}.${ctx.payload.action}`
4 if (eventName == "dependabot_alert.dismissed"){
5 await dependabotAlertDismissed(ctx);
6 }
7})
Using a defined interface:
1interface CustomEventContext {
2 name: string;
3 payload: {
4 action: string
5 };
6}
7
8app.onAny(async(context) => {
9 const ctx = context as CustomEventContext;
10 const eventName = `${context.name}.${ctx?.payload.action}`
11 if (eventName == "dependabot_alert.dismissed"){
12 await dependabotAlertDismissed(ctx);
13 }
14})
Our application is now dispatching dependabot_alert.dismissed
messages to the function dependabotAlertDismissed()
. When Probot adds support, we can replace this code block with the original app.on
call.
The event handler
The code is now almost complete. The only thing left to do is to process the event. We could process the event inline without needing a function. Refactoring the handlers into their own functions makes the code easier to test and maintain.
Because Probot is not yet aware of the new event, we don’t have a context that we can use for processing. The easiest approach is to use any
as the context type. This eliminates the type checking. Since we’re using TypeScript, it’s always better to try to have well-defined types.
Instead, let’s create an interface for this event:
1interface DependabotAlertContext {
2 name: string;
3 octokit: InstanceType<typeof ProbotOctokit>;
4 log: Logger;
5 payload: {
6 action: string;
7 repository: {
8 owner: {
9 login: string;
10 };
11 name: string;
12 };
13 alert: {
14 dismissed_by?: {
15 login: string;
16 };
17 number: number;
18 };
19 };
20}
With this, we can now invoke our function with a cast:
1await dependabotAlertDismissed(ctx as DependabotAlertContext);
And the function will have access to all of the defined properties:
1async function dependabotAlertDismissed(context: DependabotAlertContext) {
2 context.log.info("Dependabot alert event received.");
3 const owner = context.payload.repository.owner.login;
4 const user = context.payload.alert.dismissed_by?.login;
5 // More
6}
When Probot eventually adds support, the context can be replaced without having to change any other code.
The REST of it
We’ve left out one small part – invoking a new REST API endpoint. That’s also easy to handle. The Probot context can be used to manually create HTTP requests. As an example, let’s implement a call to update the Dependabot alert.
1function updateDependabotAlert(context: DependabotAlertContext, parameters: { owner: string, repo: string, alert_number: number, state: "dismissed" | "open"}){
2 const params = { state: parameters.state }
3 return context.octokit.request({
4 method: 'PATCH',
5 headers: { accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
6 url: `/repos/${parameters.owner}/${parameters.repo}/dependabot/alerts/${parameters.alert_number}`,
7 ...params
8 })
9}
The function takes in a number of parameters, including a value for state
(which must be either dismissed
or open
). It then creates a property bag called params
which holds the parameters that will be passed to the API call. The code then invokes context.octokit.request
to send a PATCH
request to the endpoint. It decomposes (...
) the params object into name-value pairs that are passed to the request method.
Why pass the parameters as an object instead of individual values? It makes it easier for us to update the code in the future. When Probot eventually adds support, we replace this:
1await updateDependabotAlert(context, {owner, repo, alert_number, "open"});
with this:
1await context.octokit.dependabot.updateAlert({owner, repo, alert_number, state: "open"});
And there you have it – everything you need to know to support new events and APIs with your Probot-based GitHub App. If you’re interested, a complete example is available here. This example creates a simple Probot application that can monitor for code scan, Dependabot, and secret scan alerts. It demonstrates how to block attempts by users to close alerts unless they are a member of a specially approved team.