Building a GitHub Issue Recommendation Bot with Algolia
Tags: actionshackathon21, javascript, github
Published: 2021-12-02 18:21:18

GitHub Issues are static content. What if they didn't have to be?

{% youtube zwJJ9FJXkCU %}

When we (DevRels Chuck Meyer and Bryan Robinson) discovered that [Dev.to was hosting a GitHub Actions hackathon](https://dev.to/devteam/join-us-for-the-2021-github-actions-hackathon-on-dev-4hn4), we knew we needed to give it a try. 

We knew we wanted to figure out a useful tool to integrate Algolia into an Action. There were obvious thoughts on what sort of project to undertake. We thought through the usual takes on indexing content, products, or markdown. They all would have been helpful for web creators. Would they have been helpful to open source maintainers, though? Probably?

How could we make their overall workflow better?

Then it struck us: What if we could give recommended Issues for frequently asked questions? Could we lessen the burden of maintainers answering similar questions? How many Issues get closed as "duplicate" in large repositories? Could Algolia provide Issue creators a list of related, helpful Issues?

Spoiler alert: Yeah, totally!

## The Workflow structure

When a developer adds an Issue to a repository, we need to execute three steps.

First, we need to search an Algolia Index for related Issues. Then, we bundle those results into Markdown and pass it to an Action to create a comment on the initial Issue. Finally, we need to put the Issue into our Index for future searches.

Each of these steps requires an Action. The Algolia-specific Actions, we needed to create from scratch. The comment-writing Action, we decided to use the amazing Peter Evan's [create-or-update-comment Action](https://github.com/marketplace/actions/create-or-update-comment) – which, as it turns out, GitHub uses in many of their docs about Actions.

Let's dive into the new Actions.

## Performing a search query

The first step of our Workflow is a search query sent to Algolia. We created a custom Action for this ([Get Algolia Issue Records](https://github.com/marketplace/actions/get-algolia-issue-records)). 

To use the Action, we need to send it four required inputs (and an optional fifth).

*   `app_id`: the ID of the application in your Algolia account. This is best stored as a Secret in your repository
*   `api_key`: An API key with search permissions to the Index in your Algolia App. This is best stored in a Secret in your repository.
*   `index_name`: The name of the Algolia Index to search. For consistency, we recommend the `github.event.repository.name` variable.
*   `issue_title`: The title of the inciting Issue found with `github.event.issue.title`.
*   `max_results`: (OPTIONAL) A number of results to return to the comment (defaults to 3)

We take these variables and perform a `similarQuery` search based on the inciting Issue's title. We then create a comment body and a list of items in Markdown (the format needed for GitHub comments). This outputs are passed to [Peter Evans' create-or-update-comment Action](https://github.com/marketplace/actions/create-or-update-comment).

```javascript
const { inspect } = require('util');
const core = require('@actions/core');
const algoliasearch = require('algoliasearch');

async function run() {
  try {
    const inputs = {
      appId: core.getInput('app_id'),
      apiKey: core.getInput('api_key'),
      indexName: core.getInput('index_name'),
      issueTitle: core.getInput('issue_title'),
      maxResults: core.getInput('max_results'),
    };
    core.info(`Inputs: ${inspect(inputs)}`);

    if (!inputs.appId && !inputs.apiKey && !inputs.indexName) {
      core.setFailed('Missing one or more of Algolia app id, API key, or index name.');
      return;
    }

    inputs.maxResults = inputs.maxResults || 3;

    const client = algoliasearch(inputs.appId, inputs.apiKey);
    const index = client.initIndex(inputs.indexName);

    index.search('', { 
        similarQuery: inputs.issueTitle,
        hitsPerPage: inputs.maxResults
      }).then(({hits}) => {
      core.info(`Searching for record`);
      core.info(`Hits: ${inspect(hits)}`);
      const message = `## Other issues similar to this one:\n${hits.map(hit => `* [${hit.title}](${hit.url})`).join('\n')}`
      const listItems = `${hits.map(hit => `* [${hit.title}](${hit.url})`).join('\n')}\n`
      core.info(message)
      core.info(listItems)
      core.setOutput('comment_body', message);
      core.setOutput('issues_list', listItems);
    })
      .catch(err => {
        core.setFailed(err.message);
      }
    );
  } catch (error) {
    core.debug(inspect(error));
    core.setFailed(error.message);
    if (error.message == 'Resource not accessible by integration') {
      core.error(`See this action's readme for details about this error`);
    }
  }
}

run();
```

## Adding the issue to your Algolia index

For the final step of our workflow, we add this new issue to the Algolia index for future searches. We created another GitHub Action for this purpose: [Create or Update Algolia Index Record](https://github.com/marketplace/actions/create-or-update-algolia-index-record). This action atomically adds/updates a record directly to an index rather than writing/reading from a JSON file. This makes sense in situations where we are acting on metadata about the repo (issues, pull requests, comments) as opposed to building an index for the application itself.

To use this action, we'll need to [create an Algolia API key](https://www.algolia.com/doc/guides/security/api-keys/#creating-and-managing-api-keys) with permissions to add/update records in our index. Additionally, we will need permission to create a new index for the repo. Otherwise, we must create it ahead of time and hard code the index name in our configuration.

Along with the new API key we'll need a few other inputs to use the action:

*   `app_id`: You should already have this as a Secret in your repository from the action above
*   `api_key`: This is the new key with permission to save records to your index. This is best stored in a Secret in your repository.
*   `index_name`: The name of the Algolia index to add/update this record. For consistency, we recommend the `github.event.repository.name` variable.
*   `record`: A string represneting the JSON record to add to the index.  

If the API key has permission, the action creates an index for the repository. We'll add the issue title and URL (to link back) as the `record`. It's a multi-line string in our workflow, but must_ be valid JSON for the action to work (see [https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/#algolia-records](https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/#algolia-records) for details).

We take all of these inputs and execute a `saveObject` call via the Algolia API. We use the `issue ID` as the `objectID` in the index. This makes it easy to tie the record back to this issue if we add workflows for update or delete events later. 

```javascript
const { inspect } = require('util');
const core = require('@actions/core');
const algoliasearch = require('algoliasearch');

async function run() {
  try {
    const inputs = {
      appId: core.getInput('app_id'),
      apiKey: core.getInput('api_key'),
      indexName: core.getInput('index_name'),
      record: core.getInput('record'),
    };
    core.debug(`Inputs: ${inspect(inputs)}`);

    if (!inputs.appId && !inputs.apiKey && !inputs.indexName) {
      core.setFailed('Missing one or more of Algolia app id, API key, or index name.');
      return;
    }

    core.info(`Writing record to index ${inputs.indexName}`)
    const client = algoliasearch(inputs.appId, inputs.apiKey);
    const index = client.initIndex(inputs.indexName);

    index.saveObject(JSON.parse(inputs.record), {'autoGenerateObjectIDIfNotExist': true})
      .then(({ objectID }) => {
        core.setOutput('object_id', objectID);
        core.info(
          `Created record in index ${inputs.indexName} with objectID ${objectID}.`
        );
      })
      .catch((err) => {
        core.setFailed(`Failed to save object: ${err}`);
      });

  } catch (error) {
    core.debug(inspect(error));
    core.setFailed(error.message);
    if (error.message == 'Resource not accessible by integration') {
      core.error(`See this action's readme for details about this error`);
    }
  }
}

run();
```

Next, we piece the two new Actions together with the existing comment creation action to build our workflow.

## The full workflow file

To make this work, we need one `job` with three `steps`. Each step will use one of these Actions.

```yaml
name: related-issues
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  issues:
    types: 
      - opened

jobs:
  get-related-issues:
    permissions: 
      # Gives the workflow write permissions only in issues
      issues: write
    runs-on: ubuntu-latest
    steps:
      # Performs a search in an Algolia Index based on Issue Title
      # The Index should have historical Issues
      # Returns two outputs:
      # issues_list: a markdown list of issues
      # comment_body: a generic comment body with the list of issues
      - id: search
        name: Search based on issue title
        uses: brob/algolia-issue-search@v1.0
        with: 
          # Requires an Algolia account with an App ID and write key
          app_id: ${{ secrets.ALGOLIA_APP_ID }}
          api_key: ${{ secrets.ALGOLIA_API_KEY }}
          index_name: ${{ github.event.repository.name }}
          issue_title: ${{ github.event.issue.title }}
      - name: Create or Update Comment
        uses: peter-evans/create-or-update-comment@v1.4.5
        with:
          # GITHUB_TOKEN or a repo scoped PAT.
          token: ${{ github.token }}
          # The number of the issue or pull request in which to create a comment.
          issue-number: ${{ github.event.issue.number }}
          # The comment body. Can use either issues_list or comment_body
          body: |
            # While you wait, here are related issues:
            ${{ steps.search.outputs.issues_list }}
            Thank you so much! We'll be with you shortly!
      # An Action to create a record in an Algolia Index
      # This is a generic Action and can be used outside of this workflow
      - name: Add Algolia Record
        id: ingest
        uses: chuckmeyer/add-algolia-record@v1
        with:
          app_id: ${{ secrets.ALGOLIA_APP_ID }}
          api_key: ${{ secrets.ALGOLIA_API_KEY }}
          index_name: ${{ github.event.repository.name }}
          # Record needs to be a string of JSON
          record: |
            {
              "title": "${{ github.event.issue.title }}", 
              "url": "${{ github.event.issue.html_url }}", 
              "labels": "${{ github.event.issue.labels }}",
              "objectID": "${{ github.event.issue.number }}"
            }
```

## Next steps

We hope that this is helpful to maintainers, but we also hope it inspires others to find better and better ways to suggest content in static areas like GitHub Issues. 

If you want to play around with the full workflow, you can check it out in [this repository](https://github.com/brob/github-issues-search-bot). Both the search and ingest Actions are available in the [GitHub marketplace](https://github.com/marketplace?type=actions&query=Algolia).

Search and discovery can become an interesting part of your automated workflow in GitHub and beyond.

Post and Bot by:
{% user chuckm %} 
{% user brob %}