Skip to content

Commit

Permalink
ci: add a github bot to support advanced PR review workflows (gnolang…
Browse files Browse the repository at this point in the history
…#3037)

This pull request aims to add a bot that extends GitHub's
functionalities like codeowners file and other merge protection
mechanisms. Interaction with the bot is done via a comment. You can test
it on the demo repo here : GnoCheckBot/demo#1

Fixes gnolang#1007 
Related to gnolang#1466, gnolang#2788

- The `config.go` file contains all the conditions and requirements in
an 'If - Then' format.
```go
// Automatic check

{
  Description: "Changes to 'tm2' folder should be reviewed/authored by at least one member of both EU and US teams",
  If: c.And(
    c.FileChanged(gh, "tm2"),
    c.BaseBranch("main"),
  ),
  Then: r.And(
    r.Or(
      r.ReviewByTeamMembers(gh, "eu", 1),
      r.AuthorInTeam(gh, "eu"),
    ),
    r.Or(
      r.ReviewByTeamMembers(gh, "us", 1),
      r.AuthorInTeam(gh, "us"),
    ),
  ),

}
```
- There are two types of checks: some are automatic and managed by the
bot (like the one above), while others are manual and need to be
verified by a specific org team member (like the one below). If no team
is specified, anyone with comment editing permission can check it.
```go
// Manual check
{
  Description: "The documentation is accurate and relevant",
  If:          c.FileChanged(gh, `.*\.md`),
  Teams: []string{
    "tech-staff",
    "devrels",
  },
},
```

- The conditions (If) allow checking, among other things, who the author
is, who is assigned, what labels are applied, the modified files, etc.
The list is available in the `condition` folder.
- The requirements (Then) allow, among other things, assigning a member,
verifying that a review is done by a specific user, applying a label,
etc. (List in `requirement` folder).
- A PR Check (the icon at the bottom with all the CI checks) will remain
orange/pending until all checks are validated, after which it will turn
green.

<img width="1065" alt="Screenshot 2024-11-05 at 18 37 34"
src="https://github.com/user-attachments/assets/efaa1657-c254-4fc1-b6d1-49c7b93d8cda">

- The Github Actions workflow associated with the bot ensures that PRs
are processed concurrently, while ensuring that the same PR is not
processed by two runners at the same time.
- We can manually process a PR by launching the workflow directly from
the [GitHub Actions
interface](https://github.com/GnoCheckBot/demo/actions/workflows/bot.yml).

<img width="313" alt="Screenshot 2024-11-06 at 01 36 42"
src="https://github.com/user-attachments/assets/287915cd-a50e-47a6-8ea1-c31383014b84">

#### To do

- [x] implement base version of the bot
- [x] cleanup code / comments
- [x] setup a demo repo
- [x] add debug printing on dry run
- [x] add some tests on requirements and conditions

<!-- please provide a detailed description of the changes made in this
pull request. -->

<details><summary>Contributors' checklist...</summary>

- [x] Added new tests, or not needed, or not feasible
- [x] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [x] Updated the official documentation or not needed
- [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [x] Added references to related issues and PRs
- [x] Provided any useful hints for running manual tests
</details>
  • Loading branch information
aeddi authored and r3v4s committed Dec 10, 2024
1 parent 761136d commit d1feab9
Show file tree
Hide file tree
Showing 56 changed files with 4,282 additions and 0 deletions.
79 changes: 79 additions & 0 deletions .github/workflows/bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: GitHub Bot

on:
# Watch for changes on PR state, assignees, labels, head branch and draft/ready status
pull_request_target:
types:
- assigned
- unassigned
- labeled
- unlabeled
- opened
- reopened
- synchronize # PR head updated
- converted_to_draft
- ready_for_review

# Watch for changes on PR comment
issue_comment:
types: [created, edited, deleted]

# Manual run from GitHub Actions interface
workflow_dispatch:
inputs:
pull-request-list:
description: "PR(s) to process: specify 'all' or a comma separated list of PR numbers, e.g. '42,1337,7890'"
required: true
default: all
type: string

jobs:
# This job creates a matrix of PR numbers based on the inputs from the various
# events that can trigger this workflow so that the process-pr job below can
# handle the parallel processing of the pull-requests
define-prs-matrix:
name: Define PRs matrix
# Prevent bot from retriggering itself
if: ${{ github.actor != vars.GH_BOT_LOGIN }}
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
pr-numbers: ${{ steps.pr-numbers.outputs.pr-numbers }}

steps:
- name: Generate matrix from event
id: pr-numbers
working-directory: contribs/github-bot
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: go run . matrix >> "$GITHUB_OUTPUT"

# This job processes each pull request in the matrix individually while ensuring
# that a same PR cannot be processed concurrently by mutliple runners
process-pr:
name: Process PR
needs: define-prs-matrix
runs-on: ubuntu-latest
strategy:
matrix:
# Run one job for each PR to process
pr-number: ${{ fromJSON(needs.define-prs-matrix.outputs.pr-numbers) }}
concurrency:
# Prevent running concurrent jobs for a given PR number
group: ${{ matrix.pr-number }}

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Run GitHub Bot
working-directory: contribs/github-bot
env:
GITHUB_TOKEN: ${{ secrets.GH_BOT_PAT }}
run: go run . -pr-numbers '${{ matrix.pr-number }}' -verbose
48 changes: 48 additions & 0 deletions contribs/github-bot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# GitHub Bot

## Overview

The GitHub Bot is designed to automate and streamline the process of managing pull requests. It can automate certain tasks such as requesting reviews, assigning users or applying labels, but it also ensures that certain requirements are satisfied before allowing a pull request to be merged. Interaction with the bot occurs through a comment on the pull request, providing all the information to the user and allowing them to check boxes for the manual validation of certain rules.

## How It Works

### Configuration

The bot operates by defining a set of rules that are evaluated against each pull request passed as parameter. These rules are categorized into automatic and manual checks:

- **Automatic Checks**: These are rules that the bot evaluates automatically. If a pull request meets the conditions specified in the rule, then the corresponding requirements are executed. For example, ensuring that changes to specific directories are reviewed by specific team members.
- **Manual Checks**: These require human intervention. If a pull request meets the conditions specified in the rule, then a checkbox that can be checked only by specified teams is displayed on the bot comment. For example, determining if infrastructure needs to be updated based on changes to specific files.

The bot configuration is defined in Go and is located in the file [config.go](./config.go).

### GitHub Token

For the bot to make requests to the GitHub API, it needs a Personal Access Token. The fine-grained permissions to assign to the token for the bot to function are:

- `pull_requests` scope to read is the bare minimum to run the bot in dry-run mode
- `pull_requests` scope to write to be able to update bot comment, assign user, apply label and request review
- `contents` scope to read to be able to check if the head branch is up to date with another one
- `commit_statuses` scope to write to be able to update pull request bot status check

## Usage

```bash
> go install github.com/gnolang/gno/contribs/github-bot@latest
// (go: downloading ...)

> github-bot --help
USAGE
github-bot [flags]

This tool checks if the requirements for a PR to be merged are satisfied (defined in config.go) and displays PR status checks accordingly.
A valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.

FLAGS
-dry-run=false print if pull request requirements are satisfied without updating anything on GitHub
-owner ... owner of the repo to process, if empty, will be retrieved from GitHub Actions context
-pr-all=false process all opened pull requests
-pr-numbers ... pull request(s) to process, must be a comma separated list of PR numbers, e.g '42,1337,7890'. If empty, will be retrieved from GitHub Actions context
-repo ... repo to process, if empty, will be retrieved from GitHub Actions context
-timeout 0s timeout after which the bot execution is interrupted
-verbose=false set logging level to debug
```
246 changes: 246 additions & 0 deletions contribs/github-bot/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package main

import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"

"github.com/gnolang/gno/contribs/github-bot/internal/client"
"github.com/gnolang/gno/contribs/github-bot/internal/logger"
p "github.com/gnolang/gno/contribs/github-bot/internal/params"
"github.com/gnolang/gno/contribs/github-bot/internal/utils"
"github.com/gnolang/gno/tm2/pkg/commands"
"github.com/google/go-github/v64/github"
"github.com/sethvargo/go-githubactions"
"github.com/xlab/treeprint"
)

func newCheckCmd() *commands.Command {
params := &p.Params{}

return commands.NewCommand(
commands.Metadata{
Name: "check",
ShortUsage: "github-bot check [flags]",
ShortHelp: "checks requirements for a pull request to be merged",
LongHelp: "This tool checks if the requirements for a pull request to be merged are satisfied (defined in config.go) and displays PR status checks accordingly.\nA valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.",
},
params,
func(_ context.Context, _ []string) error {
params.ValidateFlags()
return execCheck(params)
},
)
}

func execCheck(params *p.Params) error {
// Create context with timeout if specified in the parameters.
ctx := context.Background()
if params.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), params.Timeout)
defer cancel()
}

// Init GitHub API client.
gh, err := client.New(ctx, params)
if err != nil {
return fmt.Errorf("comment update handling failed: %w", err)
}

// Get GitHub Actions context to retrieve comment update.
actionCtx, err := githubactions.Context()
if err != nil {
gh.Logger.Debugf("Unable to retrieve GitHub Actions context: %v", err)
return nil
}

// Handle comment update, if any.
if err := handleCommentUpdate(gh, actionCtx); errors.Is(err, errTriggeredByBot) {
return nil // Ignore if this run was triggered by a previous run.
} else if err != nil {
return fmt.Errorf("comment update handling failed: %w", err)
}

// Retrieve a slice of pull requests to process.
var prs []*github.PullRequest

// If requested, retrieve all open pull requests.
if params.PRAll {
prs, err = gh.ListPR(utils.PRStateOpen)
if err != nil {
return fmt.Errorf("unable to list all PR: %w", err)
}
} else {
// Otherwise, retrieve only specified pull request(s)
// (flag or GitHub Action context).
prs = make([]*github.PullRequest, len(params.PRNums))
for i, prNum := range params.PRNums {
pr, _, err := gh.Client.PullRequests.Get(gh.Ctx, gh.Owner, gh.Repo, prNum)
if err != nil {
return fmt.Errorf("unable to retrieve specified pull request (%d): %w", prNum, err)
}
prs[i] = pr
}
}

return processPRList(gh, prs)
}

func processPRList(gh *client.GitHub, prs []*github.PullRequest) error {
if len(prs) > 1 {
prNums := make([]int, len(prs))
for i, pr := range prs {
prNums[i] = pr.GetNumber()
}

gh.Logger.Infof("%d pull requests to process: %v\n", len(prNums), prNums)
}

// Process all pull requests in parallel.
autoRules, manualRules := config(gh)
var wg sync.WaitGroup

// Used in dry-run mode to log cleanly from different goroutines.
logMutex := sync.Mutex{}

// Used in regular-run mode to return an error if one PR processing failed.
var failed atomic.Bool

for _, pr := range prs {
wg.Add(1)
go func(pr *github.PullRequest) {
defer wg.Done()
commentContent := CommentContent{}
commentContent.allSatisfied = true

// Iterate over all automatic rules in config.
for _, autoRule := range autoRules {
ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success))

// Check if conditions of this rule are met by this PR.
if !autoRule.ifC.IsMet(pr, ifDetails) {
continue
}

c := AutoContent{Description: autoRule.description, Satisfied: false}
thenDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Requirement not satisfied", utils.Fail))

// Check if requirements of this rule are satisfied by this PR.
if autoRule.thenR.IsSatisfied(pr, thenDetails) {
thenDetails.SetValue(fmt.Sprintf("%s Requirement satisfied", utils.Success))
c.Satisfied = true
} else {
commentContent.allSatisfied = false
}

c.ConditionDetails = ifDetails.String()
c.RequirementDetails = thenDetails.String()
commentContent.AutoRules = append(commentContent.AutoRules, c)
}

// Retrieve manual check states.
checks := make(map[string]manualCheckDetails)
if comment, err := gh.GetBotComment(pr.GetNumber()); err == nil {
checks = getCommentManualChecks(comment.GetBody())
}

// Iterate over all manual rules in config.
for _, manualRule := range manualRules {
ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success))

// Check if conditions of this rule are met by this PR.
if !manualRule.ifC.IsMet(pr, ifDetails) {
continue
}

// Get check status from current comment, if any.
checkedBy := ""
check, ok := checks[manualRule.description]
if ok {
checkedBy = check.checkedBy
}

commentContent.ManualRules = append(
commentContent.ManualRules,
ManualContent{
Description: manualRule.description,
ConditionDetails: ifDetails.String(),
CheckedBy: checkedBy,
Teams: manualRule.teams,
},
)

if checkedBy == "" {
commentContent.allSatisfied = false
}
}

// Logs results or write them in bot PR comment.
if gh.DryRun {
logMutex.Lock()
logResults(gh.Logger, pr.GetNumber(), commentContent)
logMutex.Unlock()
} else {
if err := updatePullRequest(gh, pr, commentContent); err != nil {
gh.Logger.Errorf("unable to update pull request: %v", err)
failed.Store(true)
}
}
}(pr)
}
wg.Wait()

if failed.Load() {
return errors.New("error occurred while processing pull requests")
}

return nil
}

// logResults is called in dry-run mode and outputs the status of each check
// and a conclusion.
func logResults(logger logger.Logger, prNum int, commentContent CommentContent) {
logger.Infof("Pull request #%d requirements", prNum)
if len(commentContent.AutoRules) > 0 {
logger.Infof("Automated Checks:")
}

for _, rule := range commentContent.AutoRules {
status := utils.Fail
if rule.Satisfied {
status = utils.Success
}
logger.Infof("%s %s", status, rule.Description)
logger.Debugf("If:\n%s", rule.ConditionDetails)
logger.Debugf("Then:\n%s", rule.RequirementDetails)
}

if len(commentContent.ManualRules) > 0 {
logger.Infof("Manual Checks:")
}

for _, rule := range commentContent.ManualRules {
status := utils.Fail
checker := "any user with comment edit permission"
if rule.CheckedBy != "" {
status = utils.Success
}
if len(rule.Teams) == 0 {
checker = fmt.Sprintf("a member of one of these teams: %s", strings.Join(rule.Teams, ", "))
}
logger.Infof("%s %s", status, rule.Description)
logger.Debugf("If:\n%s", rule.ConditionDetails)
logger.Debugf("Can be checked by %s", checker)
}

logger.Infof("Conclusion:")
if commentContent.allSatisfied {
logger.Infof("%s All requirements are satisfied\n", utils.Success)
} else {
logger.Infof("%s Not all requirements are satisfied\n", utils.Fail)
}
}
Loading

0 comments on commit d1feab9

Please sign in to comment.