Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ci: add a github bot to support advanced PR review workflows #3037

Merged
merged 44 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a009eac
feat: add github bot
aeddi Oct 28, 2024
cc585b0
typos
aeddi Nov 6, 2024
3a33f2a
chore: move folder
aeddi Nov 6, 2024
88573df
fix: typos and comments
aeddi Nov 6, 2024
8d33a1a
style: typo fix lint
aeddi Nov 7, 2024
3df876a
fix: small fixes + dry-run
aeddi Nov 7, 2024
886a83c
style: space out bash script in workflow
aeddi Nov 7, 2024
8e14e62
chore: change package path
aeddi Nov 8, 2024
42744e9
docs: add a README and improve comments
aeddi Nov 8, 2024
96060e0
test: add units tests (wip)
aeddi Nov 8, 2024
ed02af2
test: all req tested
aeddi Nov 10, 2024
3e1d086
lint: try downgrading to a compatible go version
aeddi Nov 11, 2024
5565a0d
lint: ignore lostcancel warning
aeddi Nov 11, 2024
4483391
test: add branch req + fix loop closure
aeddi Nov 11, 2024
d809c22
test: improve coverage
aeddi Nov 11, 2024
fa15c70
ci: change for pull request target
aeddi Nov 14, 2024
a03f67d
feat: add github client context cancelation
aeddi Nov 14, 2024
76f5f3d
refacto: remove useless else statement
aeddi Nov 14, 2024
61f1a43
refacto: exit from main instead of handleCommentUpdate func
aeddi Nov 14, 2024
fbe2972
style: consistency plural package names
aeddi Nov 15, 2024
f912c51
refacto: uses tm2/commands instead of stdlib flags
aeddi Nov 18, 2024
ae7fce9
refactor: remove loop var (useless in go 1.22)
aeddi Nov 20, 2024
de41ab1
test: use testify assert in tests
aeddi Nov 20, 2024
a15bc43
style: fix typos and nit picks
aeddi Nov 20, 2024
0fe9c67
chore: fix go.mod
aeddi Nov 20, 2024
3032c16
feat: return comment earlier if already fetched
aeddi Nov 20, 2024
586d970
refacor: remove Close method, create ctx from main
aeddi Nov 20, 2024
a3cf220
refactor: use standard error instead of exiting
aeddi Nov 20, 2024
e879e84
chore: remove useless Errorf
aeddi Nov 20, 2024
6d45ab9
docs: fix README and improve flag help
aeddi Nov 20, 2024
6ca5c8c
refacor: better comment, split logic, add tests
aeddi Nov 21, 2024
e161f77
refactor: make config fields private
aeddi Nov 21, 2024
49da4cf
refactor: return err instead of panic
aeddi Nov 21, 2024
ca463b1
refactor: return err to caller
aeddi Nov 21, 2024
dfc70f3
refactor: move every package in internal
aeddi Nov 21, 2024
28f1b6a
refactor: replace pending by failure in commit status
aeddi Nov 21, 2024
80b8904
refactor: replace string by Status type
aeddi Nov 21, 2024
4efa3fb
style: fix lint
aeddi Nov 21, 2024
609da31
refactor: various nits, added more tests
aeddi Nov 22, 2024
ecc6dc5
refactor: merge regex
aeddi Nov 24, 2024
f1ec277
refactor: alias author
aeddi Nov 25, 2024
d19edbe
docs: document GitHub object + consistency
aeddi Nov 25, 2024
00bc576
feat: add matrix subcommand + move bot to check
aeddi Nov 27, 2024
353d362
ci: replace bash in workflow by subcommand
aeddi Nov 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
ajnavarro marked this conversation as resolved.
Show resolved Hide resolved

# Watch for changes on PR comment
issue_comment:
types: [created, edited, deleted]
ajnavarro marked this conversation as resolved.
Show resolved Hide resolved

# 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:
aeddi marked this conversation as resolved.
Show resolved Hide resolved
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
thehowl marked this conversation as resolved.
Show resolved Hide resolved
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)
},

Check warning on line 35 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L21-L35

Added lines #L21 - L35 were not covered by tests
)
}

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()
}

Check warning on line 46 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L39-L46

Added lines #L39 - L46 were not covered by tests

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

Check warning on line 52 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L49-L52

Added lines #L49 - L52 were not covered by tests

// 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
}

Check warning on line 59 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L55-L59

Added lines #L55 - L59 were not covered by tests

// 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)
}

Check warning on line 66 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L62-L66

Added lines #L62 - L66 were not covered by tests

// 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

Check warning on line 86 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L69-L86

Added lines #L69 - L86 were not covered by tests
}
}

return processPRList(gh, prs)

Check warning on line 90 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L90

Added line #L90 was not covered by tests
}

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()
}

Check warning on line 98 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L93-L98

Added lines #L93 - L98 were not covered by tests

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

Check warning on line 100 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L100

Added line #L100 was not covered by tests
}

// 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

Check warning on line 126 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L104-L126

Added lines #L104 - L126 were not covered by tests
}

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
}

Check warning on line 138 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L129-L138

Added lines #L129 - L138 were not covered by tests

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

Check warning on line 142 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L140-L142

Added lines #L140 - L142 were not covered by tests
}

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

Check warning on line 149 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L146-L149

Added lines #L146 - L149 were not covered by tests

// 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

Check warning on line 157 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L152-L157

Added lines #L152 - L157 were not covered by tests
}

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

Check warning on line 165 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L161-L165

Added lines #L161 - L165 were not covered by tests

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

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

Check warning on line 179 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L167-L179

Added lines #L167 - L179 were not covered by tests
}

// 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)
}

Check warning on line 191 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L183-L191

Added lines #L183 - L191 were not covered by tests
}
}(pr)
}
wg.Wait()

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

Check warning on line 199 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L195-L199

Added lines #L195 - L199 were not covered by tests

return nil

Check warning on line 201 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L201

Added line #L201 was not covered by tests
}

// 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:")
}

Check warning on line 210 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L206-L210

Added lines #L206 - L210 were not covered by tests

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)

Check warning on line 219 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L212-L219

Added lines #L212 - L219 were not covered by tests
}

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

Check warning on line 224 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L222-L224

Added lines #L222 - L224 were not covered by tests

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)

Check warning on line 237 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L226-L237

Added lines #L226 - L237 were not covered by tests
}

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)
}

Check warning on line 245 in contribs/github-bot/check.go

View check run for this annotation

Codecov / codecov/patch

contribs/github-bot/check.go#L240-L245

Added lines #L240 - L245 were not covered by tests
}
Loading
Loading