As an engineer always on the lookout for new tools and technologies, I recently set myself an interesting challenge: to build something useful with GPT-4 as a coding assistant, writing as little code as possible manually.

There was a need for a specific kind of bot within our team. With numerous Git repositories spread across various GitLab groups, having a bot to to remind us about merge requests waiting for review was becoming essential. That’s how the idea of the Mergentle Reminder bot came up. By the way, I came up with the name without the help of ChatGPT and I am very proud of it 😄.

This blog post is a chronicle of my quick journey as I embarked on building the bot. With this experiment, I aimed to explore the potential of AI-assisted development, and decided to share my findings with you.

If you’d like to explore the complete source code for the bot, visit the project repository on my GitHub. However, please note that I have worked on this project a bit beyond what’s covered in the article. Feel free to give it a star if you like it!

Mergentle Reminder Bot on GitHub

Toolset

Although I have access to the GPT-4 API, I decided not to use any VS Code extensions or special tools and went straight with the regular ChatGPT browser app. I wanted to keep the setup simple intentionally.

I chose Golang as the programming language for this project mainly because it is currently my default choice for various pet and side projects. Looking ahead, it would be interesting to compare the results with other programming languages, particularly those that are more popular or higher-level, to see how the development experience and outcomes may differ when using ChatGPT.

Development process

Setting up the requirements

The key to a successful collaboration with ChatGPT was to provide clear requirements and allow the AI to ask additional questions for clarification. It’s also advised to start the prompt by setting up the role of the AI helper to make the responses more relevant.

And don’t forget to be polite - just in case 🙄.

That’s what I started from:

Please act as a backend developer proficient in Golang.

I am making a slack bot which reminds the team to review opened merge requests in GitLab. Here I will describe the requirements.

Bot periodically checks configured projects and groups in GitLab instance for opened merge requests. When it’s done, it posts a message to the configured Slack channel with a summary list of merge requests to review.

Draft merge requests are ignored.

The bot is configured with a YAML file. Secret tokens for API access are set with environment variables.

The above are the basic requirements, please ask any clarifying questions before we get started with working on this project.

As you can see, it is a pretty short mix of functional and non-functional requirements.

I encouraged ChatGPT to ask questions and it did.

Thank you for providing the requirements for the Slack bot. I have a few clarifying questions before we start working on the project:

  1. How often should the bot check for opened merge requests in GitLab? Is this interval configurable?
  2. Which GitLab API version will be used for the integration? I assume it’s the latest one, but I want to confirm.
  3. What information should be included in the summary list of merge requests posted in the Slack channel?
  4. Is there a specific format for the YAML configuration file, or should I propose a structure for it?
  5. Which specific environment variables are used for the API tokens? Is it just one for GitLab and one for Slack, or are there more?

After I have answers to these questions, I can start working on the project and provide guidance on how to implement the bot in Golang.

Here are my answers. I changed my mind and decided not to include the recurring logic into the bot itself.

  1. The bot’s main routine is executed only once per start, and then the program exits. I will be running it using cron.
  2. The latest one, but self-hosted. So the GitLab URL should be configurable.
  3. Please recommend what can be included to make it concise but useful.
  4. Propose the structure.
  5. Just for GitLab and Slack.

It didn’t take long for ChatGPT to respond.

Thank you for the clarifications. Based on your answers, I have outlined the general structure and recommendations for the bot:

  1. Since the bot will run once per start and will be executed using a cron job, there is no need to add a periodic check within the code.

  2. The bot will utilize the latest GitLab API (v4) and interact with a self-hosted GitLab instance. The GitLab URL will be configurable through the YAML file.

  3. For the summary list of merge requests posted in the Slack channel, I recommend including the following information:

  • Merge request title
  • Merge request URL
  • Author
  • Creation date
  • Number of upvotes
  • Number of downvotes
  • Status (open, merged, or closed)
  • Proposed YAML configuration file structure:
gitlab:
  url: https://your-gitlab-instance.com
  access_token_env: GITLAB_ACCESS_TOKEN
  projects:
    - project_id: 123
      group_id: 1
    - project_id: 456
      group_id: 2
slack:
  webhook_env: SLACK_WEBHOOK_URL

With this information in mind, you can start working on the Slack bot using Golang. Here are the high-level steps to implement the bot:

  1. Parse the YAML configuration file to obtain the necessary configuration parameters.

  2. Use the GitLab API client for Golang to interact with the GitLab API. You can use an existing library such as go-gitlab or create your own API client.

  3. Implement a function to fetch the opened merge requests from the configured projects and groups in GitLab, filtering out draft merge requests.

  4. Format the fetched merge requests into a concise summary, including the recommended information.

  5. Use the Slack Incoming Webhooks API to post the summary message to the configured Slack channel. You can use an existing library such as slack-go or create your own API client.

  6. Implement the main function to execute the above steps in sequence, handling errors and edge cases as necessary.

Once you have implemented the bot, you can configure the cron job to execute it periodically as needed.

That’s a very reasonable outline of the future bot. However, I have a few observations.

The structure of the config isn’t exactly what I expected. I wanted to be able to specify a project OR an entire group, so I don’t have to add tens of projects. Instead, it suggested specifying both project_id and group_id in a single entry, which doesn’t make much sense. Well, not a big deal - probably my description wasn’t very clear.

Another peculiar decision is that it opted to make ENV variable names configurable, instead of simply allowing the ability to set these two parameters with predefined variables. It’s not necessarily wrong logic, just excessive.

A proposal to include merge request status (open, merged, or closed) doesn’t make sense, as the whole point of the bot is to remind about opened merge requests.

Here is how I’m trying to fix the config structure:

Let’s instead put two collections on the top level of yaml config, projects and groups. They will contain id key with the value of project ID or group ID respectively. For every group specified, all projects in that group should be scanned. It will be a convenient way to configure the whole group. Also, remove token and webhook params from the config, the variable names should be constant.

Now it got me right.

gitlab:
  url: https://your-gitlab-instance.com
projects:
  - id: 123
  - id: 456
groups:
  - id: 1
  - id: 2

I’ve also asked ChatGPT to generate me a reasonable .gitignore file for the project, but I will omit this part.

It’s time to write some real code.

Tinkering with the code

Generate the code. Don’t put everything in main function, break the code by functions when it makes sense.

I’ll put the listing under the spoiler, as it’s quite long.

Here’s an example implementation of the Slack bot in Golang, broken down into several functions for better code organization and readability:

Expand code listing
package main

import (
	"fmt"
	"github.com/slack-go/slack"
	"github.com/xanzy/go-gitlab"
	"gopkg.in/yaml.v2"
	"io/ioutil"
	"os"
)

type Config struct {
	GitLab struct {
		URL string `yaml:"url"`
	} `yaml:"gitlab"`
	Projects []struct {
		ID int `yaml:"id"`
	} `yaml:"projects"`
	Groups []struct {
		ID int `yaml:"id"`
	} `yaml:"groups"`
}

func readConfig(file string) (*Config, error) {
	data, err := ioutil.ReadFile(file)
	if err != nil {
		return nil, err
	}

	var config Config
	err = yaml.Unmarshal(data, &config)
	if err != nil {
		return nil, err
	}

	return &config, nil
}

func fetchProjectsFromGroups(config *Config, client *gitlab.Client) ([]int, error) {
	var projectIDs []int
	for _, group := range config.Groups {
		options := &gitlab.ListGroupProjectsOptions{}
		projects, _, err := client.Groups.ListGroupProjects(group.ID, options)
		if err != nil {
			return nil, err
		}

		for _, project := range projects {
			projectIDs = append(projectIDs, project.ID)
		}
	}

	return projectIDs, nil
}

func fetchOpenedMergeRequests(config *Config, client *gitlab.Client) ([]*gitlab.MergeRequest, error) {
	var allMRs []*gitlab.MergeRequest

	// Add projects from groups to the projects list
	projectIDs, err := fetchProjectsFromGroups(config, client)
	if err != nil {
		return nil, err
	}
	for _, id := range projectIDs {
		config.Projects = append(config.Projects, struct{ ID int }{ID: id})
	}

	for _, project := range config.Projects {
		options := &gitlab.ListProjectMergeRequestsOptions{
			State:       gitlab.String("opened"),
			OrderBy:     gitlab.String("updated_at"),
			Sort:        gitlab.String("desc"),
			WithWIP:     gitlab.String("No"),
		}

		mrs, _, err := client.MergeRequests.ListProjectMergeRequests(project.ID, options)
		if err != nil {
			return nil, err
		}

		allMRs = append(allMRs, mrs...)
	}

	return allMRs, nil
}

func formatMergeRequestsSummary(mrs []*gitlab.MergeRequest) string {
	var summary string
	for _, mr := range mrs {
		summary += fmt.Sprintf(
			"Title: %s\nURL: %s\nAuthor: %s\nCreated at: %s\nUpvotes: %d\nDownvotes: %d\nStatus: %s\n\n",
			mr.Title, mr.WebURL, mr.Author.Name, mr.CreatedAt, mr.Upvotes, mr.Downvotes, mr.State,
		)
	}

	return summary
}

func sendSlackMessage(webhookURL, message string) error {
	msg := slack.WebhookMessage{
		Text: message,
	}
	return slack.PostWebhook(webhookURL, &msg)
}

func main() {
	config, err := readConfig("config.yml")
	if err != nil {
		fmt.Printf("Error reading configuration file: %v\n", err)
		os.Exit(1)
	}

	gitlabClient := gitlab.NewClient(nil, os.Getenv("GITLAB_ACCESS_TOKEN"))
	gitlabClient.SetBaseURL(config.GitLab.URL)

	mrs, err := fetchOpenedMergeRequests(config, gitlabClient)
	if err != nil {
		fmt.Printf("Error fetching opened merge requests: %v\n", err)
		os.Exit(1)
	}

	summary := formatMergeRequestsSummary(mrs)

	slackWebhookURL := os.Getenv("SLACK_WEBHOOK_URL")
	err = sendSlackMessage(slackWebhookURL, summary)
	if err != nil {
		fmt.Printf("Error sending Slack message: %v\n", err)
		os.Exit(1)
	}

	fmt.Println("Successfully sent merge request summary to Slack.")
}

It is idiomatic, well-organized, easy to read code. It’s not perfect, it doesn’t even compile straight away. I had to change a few things here and there before it actually printed the summary, but it’s a really good start.

Also, I noticed that it completely ignored API pagination, so in case of a large number of projects in a group or merge requests in a project, it would only fetch the first page of results. I specifically asked to fix that.

I’m not sure if all projects and all merge requests will be fetched if there are many of them. Should we implement pagination?

After pointing to this gap in the logic, it produced almost correct code of two functions.

You’re right. The GitLab API and the go-gitlab library use pagination, which means that by default, only a limited number of results are returned per request. To ensure that you fetch all projects and merge requests, you’ll need to implement pagination.

Here’s how you can update the fetchProjectsFromGroups and fetchOpenedMergeRequests functions to handle pagination:

Expand code listing
func fetchProjectsFromGroups(config *Config, client *gitlab.Client) ([]int, error) {
	var projectIDs []int
	for _, group := range config.Groups {
		options := &gitlab.ListGroupProjectsOptions{
			ListOptions: gitlab.ListOptions{
				PerPage: 50,
				Page:    1,
			},
		}

		for {
			projects, resp, err := client.Groups.ListGroupProjects(group.ID, options)
			if err != nil {
				return nil, err
			}

			for _, project := range projects {
				projectIDs = append(projectIDs, project.ID)
			}

			if resp.CurrentPage >= resp.TotalPages {
				break
			}

			options.Page = resp.NextPage
		}
	}

	return projectIDs, nil
}

func fetchOpenedMergeRequests(config *Config, client *gitlab.Client) ([]*gitlab.MergeRequest, error) {
	var allMRs []*gitlab.MergeRequest

	// Add projects from groups to the projects list
	projectIDs, err := fetchProjectsFromGroups(config, client)
	if err != nil {
		return nil, err
	}
	for _, id := range projectIDs {
		config.Projects = append(config.Projects, struct{ ID int }{ID: id})
	}

	for _, project := range config.Projects {
		options := &gitlab.ListProjectMergeRequestsOptions{
			State:       gitlab.String("opened"),
			OrderBy:     gitlab.String("updated_at"),
			Sort:        gitlab.String("desc"),
			WithWIP:     gitlab.String("No"),
			ListOptions: gitlab.ListOptions{
				PerPage: 50,
				Page:    1,
			},
		}

		for {
			mrs, resp, err := client.MergeRequests.ListProjectMergeRequests(project.ID, options)
			if err != nil {
				return nil, err
			}

			allMRs = append(allMRs, mrs...)

			if resp.CurrentPage >= resp.TotalPages {
				break
			}

			options.Page = resp.NextPage
		}
	}

	return allMRs, nil
}

Then I decided to include the list of users who have approved the merge request. And I have to say - that was the most complicated part. It was a dialog of many messages, with ChatGPT hallucinating about non-existing library functions and structure fields, and me posting compile errors and asking to fix them again and again. Let’s say, we agreed on me writing the most of this part, and ChatGPT just polishing it a bit.

Ok, the code is ready. Let’s test it (TDD out of the room).

Unit tests

As I know that in Go, I cannot mock and stub the whole world as it is often done in Ruby, I started my request with the ask to refactor the code.

Let’s refactor the code to make it more testable, and write unit tests for it.

That’s what ChatGPT responded with:

To make the code more testable, we can refactor it by splitting the logic into smaller, more focused functions that can be independently tested. We’ll also need to mock external dependencies such as GitLab and Slack APIs to test their > interactions without actually making the API calls. Here’s a possible refactoring of the code:

Create a new file named gitlab.go and move the GitLab-related functions, such as fetchOpenedMergeRequests and fetchProjectsFromGroups, to this file. Similarly, create a new file named slack.go and move the Slack-related functions, such as sendSlackMessage and formatMergeRequestsSummary, to this file. Now, let’s create interfaces for GitLab and Slack clients to make it easier to mock their behavior during testing:

gitlab.go:

package main

import (
	// imports
)

type GitLabClient interface {
	// Define the required methods for the GitLab client
}

type gitLabClient struct {
	client *gitlab.Client
}

// Implement GitLabClient interface for gitLabClient

// Move GitLab-related functions here

slack.go:

package main

import (
	// imports
)

type SlackClient interface {
	// Define the required methods for the Slack client
}

type slackClient struct {
	webhookURL string
}

// Implement SlackClient interface for slackClient

// Move Slack-related functions here

Update main.go to use the new interfaces and provide instances of the concrete implementations:

main.go:

package main

import (
	// imports
)

func main() {
	// Read the config
	config, err := readConfig("config.yaml")
	// Handle errors

	// Create GitLab client
	gitlabClient := &gitLabClient{client: gitlab.NewClient(/* ... */)}

	// Create Slack client
	slackClient := &slackClient{webhookURL: /* ... */}

	// Fetch merge requests and send a Slack message
	// ...
}

Now that we have separated the logic into smaller functions and introduced interfaces, we can write unit tests using mock implementations of the GitLab and Slack clients.

Create a main_test.go file, and use a mocking library like GoMock to generate mock implementations of the GitLabClient and SlackClient interfaces. Then, write test cases for the individual functions, asserting the expected behavior > and interactions with the mocked clients.

I hope this helps you refactor the code for better testability and write unit tests for the application.

Who am I to argue with AI? I just followed the instructions and wrote the tests. By the way, that was exactly what I would do if I was writing this code myself. I only asked to use mockery library instead, just because I like it more.

I won’t go into further details here. ChatGPT helped to generate the mocks, provided a basic test structure and even some working tests. Well, almost working, as usual. Additionally, I manually covered a few corner cases. Pretty sure that ChatGPT would have done it too if I asked, but I was a bit tired of that conversation already.

Infrastructure and deployment

That’s where it shines. I suspect it is because of way more declarative nature of all these configs, comparing to the code. I asked my virtual friend to write a Dockerfile, a Kubernetes manifest and a GitHub action to run tests, build the image and push it to the registry.

The result was pretty good, only slightly outdated when it comes to versions (which is totally understandable). I won’t say that I left everything as it was, but I mostly polished it according to my own preferences.

The main prompt was:

Let’s work on the infrastructure and deployment topic. I want to push this code to GitHub repository, configure an action to build and push Docker image, and deploy it to Kubernetes cluster as a CronJob. Help me to write Dockerfile, k8s manifests and GitHub actions config.

Conclusion

So, I’ve had a pretty interesting ride building the “Mergentle Reminder” bot with ChatGPT, and it showed me how AI-assisted development can be a real game-changer when you know what you’re doing.

Let’s be honest, coding can be the most boring part sometimes, so having an AI language model take care of that while I focus on the bigger picture is pretty sweet. Plus, when you’re feeling stuck or blocked (hello, writer’s block), having it around to help you get started makes a huge difference.

Of course, AI tools like ChatGPT aren’t a magic wand. You’ve still got to know your stuff and be able to clearly explain your requirements and decisions, whether you’re working with people or AI. Sometimes, it messes up the details, and getting it to fix is more complicated and annoying than actually doing it by myself.

The bottom line is, AI-assisted development can be a fantastic productivity booster for experienced devs who can make the most of these tools. The more we learn to work with them and sharpen our communication skills, the more amazing stuff we can build.

P.S.

What do you think about “Mergentle Reminder” name for this integration?

“Mergentle Reminder” is a creative and clever name for your integration! It combines the concept of “merge” with “gentle reminder,” which accurately describes the purpose of your Slack bot. It’s a unique and memorable name that should resonate well with users.