-
Notifications
You must be signed in to change notification settings - Fork 377
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ci: add a github bot to support advanced PR review workflows (#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 #1007 Related to #1466, #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
Showing
56 changed files
with
4,282 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.