Skip to content

Commit 0d2d507

Browse files
aeddialbttx
authored andcommitted
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>
1 parent b2dd24a commit 0d2d507

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+4282
-0
lines changed

.github/workflows/bot.yml

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: GitHub Bot
2+
3+
on:
4+
# Watch for changes on PR state, assignees, labels, head branch and draft/ready status
5+
pull_request_target:
6+
types:
7+
- assigned
8+
- unassigned
9+
- labeled
10+
- unlabeled
11+
- opened
12+
- reopened
13+
- synchronize # PR head updated
14+
- converted_to_draft
15+
- ready_for_review
16+
17+
# Watch for changes on PR comment
18+
issue_comment:
19+
types: [created, edited, deleted]
20+
21+
# Manual run from GitHub Actions interface
22+
workflow_dispatch:
23+
inputs:
24+
pull-request-list:
25+
description: "PR(s) to process: specify 'all' or a comma separated list of PR numbers, e.g. '42,1337,7890'"
26+
required: true
27+
default: all
28+
type: string
29+
30+
jobs:
31+
# This job creates a matrix of PR numbers based on the inputs from the various
32+
# events that can trigger this workflow so that the process-pr job below can
33+
# handle the parallel processing of the pull-requests
34+
define-prs-matrix:
35+
name: Define PRs matrix
36+
# Prevent bot from retriggering itself
37+
if: ${{ github.actor != vars.GH_BOT_LOGIN }}
38+
runs-on: ubuntu-latest
39+
permissions:
40+
pull-requests: read
41+
outputs:
42+
pr-numbers: ${{ steps.pr-numbers.outputs.pr-numbers }}
43+
44+
steps:
45+
- name: Generate matrix from event
46+
id: pr-numbers
47+
working-directory: contribs/github-bot
48+
env:
49+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50+
run: go run . matrix >> "$GITHUB_OUTPUT"
51+
52+
# This job processes each pull request in the matrix individually while ensuring
53+
# that a same PR cannot be processed concurrently by mutliple runners
54+
process-pr:
55+
name: Process PR
56+
needs: define-prs-matrix
57+
runs-on: ubuntu-latest
58+
strategy:
59+
matrix:
60+
# Run one job for each PR to process
61+
pr-number: ${{ fromJSON(needs.define-prs-matrix.outputs.pr-numbers) }}
62+
concurrency:
63+
# Prevent running concurrent jobs for a given PR number
64+
group: ${{ matrix.pr-number }}
65+
66+
steps:
67+
- name: Checkout code
68+
uses: actions/checkout@v4
69+
70+
- name: Install Go
71+
uses: actions/setup-go@v5
72+
with:
73+
go-version-file: go.mod
74+
75+
- name: Run GitHub Bot
76+
working-directory: contribs/github-bot
77+
env:
78+
GITHUB_TOKEN: ${{ secrets.GH_BOT_PAT }}
79+
run: go run . -pr-numbers '${{ matrix.pr-number }}' -verbose

contribs/github-bot/README.md

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# GitHub Bot
2+
3+
## Overview
4+
5+
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.
6+
7+
## How It Works
8+
9+
### Configuration
10+
11+
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:
12+
13+
- **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.
14+
- **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.
15+
16+
The bot configuration is defined in Go and is located in the file [config.go](./config.go).
17+
18+
### GitHub Token
19+
20+
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:
21+
22+
- `pull_requests` scope to read is the bare minimum to run the bot in dry-run mode
23+
- `pull_requests` scope to write to be able to update bot comment, assign user, apply label and request review
24+
- `contents` scope to read to be able to check if the head branch is up to date with another one
25+
- `commit_statuses` scope to write to be able to update pull request bot status check
26+
27+
## Usage
28+
29+
```bash
30+
> go install github.com/gnolang/gno/contribs/github-bot@latest
31+
// (go: downloading ...)
32+
33+
> github-bot --help
34+
USAGE
35+
github-bot [flags]
36+
37+
This tool checks if the requirements for a PR to be merged are satisfied (defined in config.go) and displays PR status checks accordingly.
38+
A valid GitHub Token must be provided by setting the GITHUB_TOKEN environment variable.
39+
40+
FLAGS
41+
-dry-run=false print if pull request requirements are satisfied without updating anything on GitHub
42+
-owner ... owner of the repo to process, if empty, will be retrieved from GitHub Actions context
43+
-pr-all=false process all opened pull requests
44+
-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
45+
-repo ... repo to process, if empty, will be retrieved from GitHub Actions context
46+
-timeout 0s timeout after which the bot execution is interrupted
47+
-verbose=false set logging level to debug
48+
```

contribs/github-bot/check.go

+246
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"sync"
9+
"sync/atomic"
10+
11+
"github.com/gnolang/gno/contribs/github-bot/internal/client"
12+
"github.com/gnolang/gno/contribs/github-bot/internal/logger"
13+
p "github.com/gnolang/gno/contribs/github-bot/internal/params"
14+
"github.com/gnolang/gno/contribs/github-bot/internal/utils"
15+
"github.com/gnolang/gno/tm2/pkg/commands"
16+
"github.com/google/go-github/v64/github"
17+
"github.com/sethvargo/go-githubactions"
18+
"github.com/xlab/treeprint"
19+
)
20+
21+
func newCheckCmd() *commands.Command {
22+
params := &p.Params{}
23+
24+
return commands.NewCommand(
25+
commands.Metadata{
26+
Name: "check",
27+
ShortUsage: "github-bot check [flags]",
28+
ShortHelp: "checks requirements for a pull request to be merged",
29+
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.",
30+
},
31+
params,
32+
func(_ context.Context, _ []string) error {
33+
params.ValidateFlags()
34+
return execCheck(params)
35+
},
36+
)
37+
}
38+
39+
func execCheck(params *p.Params) error {
40+
// Create context with timeout if specified in the parameters.
41+
ctx := context.Background()
42+
if params.Timeout > 0 {
43+
var cancel context.CancelFunc
44+
ctx, cancel = context.WithTimeout(context.Background(), params.Timeout)
45+
defer cancel()
46+
}
47+
48+
// Init GitHub API client.
49+
gh, err := client.New(ctx, params)
50+
if err != nil {
51+
return fmt.Errorf("comment update handling failed: %w", err)
52+
}
53+
54+
// Get GitHub Actions context to retrieve comment update.
55+
actionCtx, err := githubactions.Context()
56+
if err != nil {
57+
gh.Logger.Debugf("Unable to retrieve GitHub Actions context: %v", err)
58+
return nil
59+
}
60+
61+
// Handle comment update, if any.
62+
if err := handleCommentUpdate(gh, actionCtx); errors.Is(err, errTriggeredByBot) {
63+
return nil // Ignore if this run was triggered by a previous run.
64+
} else if err != nil {
65+
return fmt.Errorf("comment update handling failed: %w", err)
66+
}
67+
68+
// Retrieve a slice of pull requests to process.
69+
var prs []*github.PullRequest
70+
71+
// If requested, retrieve all open pull requests.
72+
if params.PRAll {
73+
prs, err = gh.ListPR(utils.PRStateOpen)
74+
if err != nil {
75+
return fmt.Errorf("unable to list all PR: %w", err)
76+
}
77+
} else {
78+
// Otherwise, retrieve only specified pull request(s)
79+
// (flag or GitHub Action context).
80+
prs = make([]*github.PullRequest, len(params.PRNums))
81+
for i, prNum := range params.PRNums {
82+
pr, _, err := gh.Client.PullRequests.Get(gh.Ctx, gh.Owner, gh.Repo, prNum)
83+
if err != nil {
84+
return fmt.Errorf("unable to retrieve specified pull request (%d): %w", prNum, err)
85+
}
86+
prs[i] = pr
87+
}
88+
}
89+
90+
return processPRList(gh, prs)
91+
}
92+
93+
func processPRList(gh *client.GitHub, prs []*github.PullRequest) error {
94+
if len(prs) > 1 {
95+
prNums := make([]int, len(prs))
96+
for i, pr := range prs {
97+
prNums[i] = pr.GetNumber()
98+
}
99+
100+
gh.Logger.Infof("%d pull requests to process: %v\n", len(prNums), prNums)
101+
}
102+
103+
// Process all pull requests in parallel.
104+
autoRules, manualRules := config(gh)
105+
var wg sync.WaitGroup
106+
107+
// Used in dry-run mode to log cleanly from different goroutines.
108+
logMutex := sync.Mutex{}
109+
110+
// Used in regular-run mode to return an error if one PR processing failed.
111+
var failed atomic.Bool
112+
113+
for _, pr := range prs {
114+
wg.Add(1)
115+
go func(pr *github.PullRequest) {
116+
defer wg.Done()
117+
commentContent := CommentContent{}
118+
commentContent.allSatisfied = true
119+
120+
// Iterate over all automatic rules in config.
121+
for _, autoRule := range autoRules {
122+
ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success))
123+
124+
// Check if conditions of this rule are met by this PR.
125+
if !autoRule.ifC.IsMet(pr, ifDetails) {
126+
continue
127+
}
128+
129+
c := AutoContent{Description: autoRule.description, Satisfied: false}
130+
thenDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Requirement not satisfied", utils.Fail))
131+
132+
// Check if requirements of this rule are satisfied by this PR.
133+
if autoRule.thenR.IsSatisfied(pr, thenDetails) {
134+
thenDetails.SetValue(fmt.Sprintf("%s Requirement satisfied", utils.Success))
135+
c.Satisfied = true
136+
} else {
137+
commentContent.allSatisfied = false
138+
}
139+
140+
c.ConditionDetails = ifDetails.String()
141+
c.RequirementDetails = thenDetails.String()
142+
commentContent.AutoRules = append(commentContent.AutoRules, c)
143+
}
144+
145+
// Retrieve manual check states.
146+
checks := make(map[string]manualCheckDetails)
147+
if comment, err := gh.GetBotComment(pr.GetNumber()); err == nil {
148+
checks = getCommentManualChecks(comment.GetBody())
149+
}
150+
151+
// Iterate over all manual rules in config.
152+
for _, manualRule := range manualRules {
153+
ifDetails := treeprint.NewWithRoot(fmt.Sprintf("%s Condition met", utils.Success))
154+
155+
// Check if conditions of this rule are met by this PR.
156+
if !manualRule.ifC.IsMet(pr, ifDetails) {
157+
continue
158+
}
159+
160+
// Get check status from current comment, if any.
161+
checkedBy := ""
162+
check, ok := checks[manualRule.description]
163+
if ok {
164+
checkedBy = check.checkedBy
165+
}
166+
167+
commentContent.ManualRules = append(
168+
commentContent.ManualRules,
169+
ManualContent{
170+
Description: manualRule.description,
171+
ConditionDetails: ifDetails.String(),
172+
CheckedBy: checkedBy,
173+
Teams: manualRule.teams,
174+
},
175+
)
176+
177+
if checkedBy == "" {
178+
commentContent.allSatisfied = false
179+
}
180+
}
181+
182+
// Logs results or write them in bot PR comment.
183+
if gh.DryRun {
184+
logMutex.Lock()
185+
logResults(gh.Logger, pr.GetNumber(), commentContent)
186+
logMutex.Unlock()
187+
} else {
188+
if err := updatePullRequest(gh, pr, commentContent); err != nil {
189+
gh.Logger.Errorf("unable to update pull request: %v", err)
190+
failed.Store(true)
191+
}
192+
}
193+
}(pr)
194+
}
195+
wg.Wait()
196+
197+
if failed.Load() {
198+
return errors.New("error occurred while processing pull requests")
199+
}
200+
201+
return nil
202+
}
203+
204+
// logResults is called in dry-run mode and outputs the status of each check
205+
// and a conclusion.
206+
func logResults(logger logger.Logger, prNum int, commentContent CommentContent) {
207+
logger.Infof("Pull request #%d requirements", prNum)
208+
if len(commentContent.AutoRules) > 0 {
209+
logger.Infof("Automated Checks:")
210+
}
211+
212+
for _, rule := range commentContent.AutoRules {
213+
status := utils.Fail
214+
if rule.Satisfied {
215+
status = utils.Success
216+
}
217+
logger.Infof("%s %s", status, rule.Description)
218+
logger.Debugf("If:\n%s", rule.ConditionDetails)
219+
logger.Debugf("Then:\n%s", rule.RequirementDetails)
220+
}
221+
222+
if len(commentContent.ManualRules) > 0 {
223+
logger.Infof("Manual Checks:")
224+
}
225+
226+
for _, rule := range commentContent.ManualRules {
227+
status := utils.Fail
228+
checker := "any user with comment edit permission"
229+
if rule.CheckedBy != "" {
230+
status = utils.Success
231+
}
232+
if len(rule.Teams) == 0 {
233+
checker = fmt.Sprintf("a member of one of these teams: %s", strings.Join(rule.Teams, ", "))
234+
}
235+
logger.Infof("%s %s", status, rule.Description)
236+
logger.Debugf("If:\n%s", rule.ConditionDetails)
237+
logger.Debugf("Can be checked by %s", checker)
238+
}
239+
240+
logger.Infof("Conclusion:")
241+
if commentContent.allSatisfied {
242+
logger.Infof("%s All requirements are satisfied\n", utils.Success)
243+
} else {
244+
logger.Infof("%s Not all requirements are satisfied\n", utils.Fail)
245+
}
246+
}

0 commit comments

Comments
 (0)