diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
index 0870103c1b95..f51cecd92695 100644
--- a/.github/workflows/bot.yml
+++ b/.github/workflows/bot.yml
@@ -1,5 +1,8 @@
+name: GitHub Bot
+
on:
- pull_request: # Watch changes on PR assignement, label and code
+ # Watch for changes on PR state, assignees, labels and head branch
+ pull_request:
types:
- assigned
- unassigned
@@ -7,56 +10,95 @@ on:
- unlabeled
- opened
- reopened
- - synchronize # PR code updated
+ - synchronize # PR head updated
- issue_comment: # Watch PR comment changes
+ # Watch for changes on PR comment
+ issue_comment:
types: [created, edited, deleted]
- workflow_dispatch: # Manual run from Github Actions interface
+ # 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 like `42,1337,7890`."
+ description: "PR(s) to process : specify 'all' or a comma separated list of PR numbers, e.g. '42,1337,7890'"
required: true
- default: "all"
+ default: all
type: string
jobs:
- prevent-concurrency:
+ # 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: Set PR numbers inside matrix
+ - name: Parse event inputs
id: pr-numbers
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- if [ "${{ inputs.perform_deploy }}" = "all" ]; then
- echo 'pr-numbers=[""]' >> "$GITHUB_OUTPUT"
+ # Triggered by a workflow dispatch event
+ if [ '${{ github.event_name }}' = 'workflow_dispatch' ]; then
+ # If the input is 'all', create a matrix with every open PRs
+ if [ '${{ inputs.pull-request-list }}' = 'all' ]; then
+ pr_list=`gh pr list --state 'open' --repo '${{ github.repository }}' --json 'number' --template '{{$first := true}}{{range .}}{{if $first}}{{$first = false}}{{else}}, {{end}}{{"\""}}{{.number}}{{"\""}}{{end}}'`
+ [ -z "$pr_list" ] && echo 'Error : no opened PR found' >&2 && exit 1
+ echo "pr-numbers=[$pr_list]" >> "$GITHUB_OUTPUT"
+ # If the input is not 'all', test for each number in the comma separated
+ # list if the associated PR is opened, then add it to the matrix
else
- echo 'pr-numbers=[""]' >> "$GITHUB_OUTPUT"
+ pr_list_raw='${{ inputs.pull-request-list }}'
+ pr_list=''
+ IFS=','
+ for number in $pr_list; do
+ trimed=`echo "$number" | xargs`
+ pr_state=`gh pr view "$trimed" --repo '${{ github.repository }}' --json 'state' --template '{{.state}}' 2> /dev/null`
+ [ "$pr_state" != 'OPEN' ] && echo "Error : PR with number <$trimed> is not opened" >&2 && exit 1
+ done
+ echo "pr-numbers=[$pr_list]" >> "$GITHUB_OUTPUT"
fi
+ # Triggered by comment event, just add the associated PR number to the matrix
+ elif [ '${{ github.event_name }}' = 'issue_comment' ]; then
+ echo 'pr-numbers=["${{ github.event.issue.number }}"]' >> "$GITHUB_OUTPUT"
+ # Triggered by pull request event, just add the associated PR number to the matrix
+ elif [ '${{ github.event_name }}' = 'pull_request' ]; then
+ echo 'pr-numbers=["${{ github.event.pull_request.number }}"]' >> "$GITHUB_OUTPUT"
else
- echo 'pr-numbers=[""]' >> "$GITHUB_OUTPUT"
+ echo 'Error : unknown event ${{ github.event_name }}' >&2 && exit 1
fi
+ # 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:
- needs: prevent-concurrency
+ name: Process PR
+ needs: define-prs-matrix
runs-on: ubuntu-latest
strategy:
matrix:
- pr-number: ${{ fromJSON(needs.prevent-concurrency.outputs.pr-numbers) }}
+ # 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
+ - name: Checkout code
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
- go-version-file: "go.mod"
+ go-version-file: go.mod
- - name: Start bot
+ - name: Run GitHub Bot
env:
- GITHUB_TOKEN: ${{ secrets.PAT }}
- run: go run .
+ GITHUB_TOKEN: ${{ secrets.GH_BOT_PAT }}
+ run: go run . -pr-numbers '${{ matrix.pr-number }}' -verbose
diff --git a/contribs/github_bot/client/client.go b/contribs/github_bot/client/client.go
new file mode 100644
index 000000000000..5a011573be4c
--- /dev/null
+++ b/contribs/github_bot/client/client.go
@@ -0,0 +1,235 @@
+package client
+
+import (
+ "bot/logger"
+ "bot/param"
+ "context"
+ "log"
+ "os"
+ "time"
+
+ "github.com/google/go-github/v66/github"
+)
+
+const PageSize = 100
+
+type GitHub struct {
+ Client *github.Client
+ Ctx context.Context
+ DryRun bool
+ Logger logger.Logger
+ Owner string
+ Repo string
+}
+
+func (gh *GitHub) GetBotComment(prNum int) *github.IssueComment {
+ // List existing comments
+ var (
+ allComments []*github.IssueComment
+ sort = "created"
+ direction = "desc"
+ opts = &github.IssueListCommentsOptions{
+ Sort: &sort,
+ Direction: &direction,
+ ListOptions: github.ListOptions{
+ PerPage: PageSize,
+ },
+ }
+ )
+
+ for {
+ comments, response, err := gh.Client.Issues.ListComments(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ prNum,
+ opts,
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to list comments for PR %d : %v", prNum, err)
+ return nil
+ }
+
+ allComments = append(allComments, comments...)
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ // Get current user (bot)
+ currentUser, _, err := gh.Client.Users.Get(gh.Ctx, "")
+ if err != nil {
+ gh.Logger.Errorf("Unable to get current user : %v", err)
+ return nil
+ }
+
+ // Get the comment created by current user
+ for _, comment := range allComments {
+ if comment.GetUser().GetLogin() == currentUser.GetLogin() {
+ return comment
+ }
+ }
+
+ return nil
+}
+
+func (gh *GitHub) SetBotComment(body string, prNum int) *github.IssueComment {
+ // Create bot comment if it not already exists
+ if comment := gh.GetBotComment(prNum); comment == nil {
+ newComment, _, err := gh.Client.Issues.CreateComment(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ prNum,
+ &github.IssueComment{Body: &body},
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to create bot comment for PR %d : %v", prNum, err)
+ return nil
+ }
+ return newComment
+ } else {
+ comment.Body = &body
+ editComment, _, err := gh.Client.Issues.EditComment(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ comment.GetID(),
+ comment,
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to edit bot comment with ID %d : %v", comment.GetID(), err)
+ return nil
+ }
+ return editComment
+ }
+}
+
+func (gh *GitHub) ListTeamMembers(team string) []*github.User {
+ var (
+ allMembers []*github.User
+ opts = &github.TeamListTeamMembersOptions{
+ ListOptions: github.ListOptions{
+ PerPage: PageSize,
+ },
+ }
+ )
+
+ for {
+ members, response, err := gh.Client.Teams.ListTeamMembersBySlug(
+ gh.Ctx,
+ gh.Owner,
+ team,
+ opts,
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to list members for team %s : %v", team, err)
+ return nil
+ }
+
+ allMembers = append(allMembers, members...)
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ return allMembers
+}
+
+func (gh *GitHub) ListPrReviewers(prNum int) *github.Reviewers {
+ var (
+ allReviewers = &github.Reviewers{}
+ opts = &github.ListOptions{
+ PerPage: PageSize,
+ }
+ )
+
+ for {
+ reviewers, response, err := gh.Client.PullRequests.ListReviewers(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ prNum,
+ opts,
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to list reviewers for PR %d : %v", prNum, err)
+ return nil
+ }
+
+ allReviewers.Teams = append(allReviewers.Teams, reviewers.Teams...)
+ allReviewers.Users = append(allReviewers.Users, reviewers.Users...)
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ return allReviewers
+}
+
+func (gh *GitHub) ListPrReviews(prNum int) []*github.PullRequestReview {
+ var (
+ allReviews []*github.PullRequestReview
+ opts = &github.ListOptions{
+ PerPage: PageSize,
+ }
+ )
+
+ for {
+ reviews, response, err := gh.Client.PullRequests.ListReviews(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ prNum,
+ opts,
+ )
+ if err != nil {
+ gh.Logger.Errorf("Unable to list reviews for PR %d : %v", prNum, err)
+ return nil
+ }
+
+ allReviews = append(allReviews, reviews...)
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ return allReviews
+}
+
+func New(params param.Params) *GitHub {
+ gh := &GitHub{
+ Owner: params.Owner,
+ Repo: params.Repo,
+ DryRun: params.DryRun,
+ }
+
+ // This method will detect if the current process was launched by
+ // a GitHub Action or not and will accordingly return a logger suitable for
+ // the terminal output or for the GitHub Actions web interface
+ gh.Logger = logger.NewLogger(params.Verbose)
+
+ // Create context with timeout if specified in flags
+ if params.Timeout > 0 {
+ gh.Ctx, _ = context.WithTimeout(context.Background(), time.Duration(params.Timeout)*time.Millisecond)
+ } else {
+ gh.Ctx = context.Background()
+ }
+
+ // Init GitHub API Client using token from env
+ token, set := os.LookupEnv("GITHUB_TOKEN")
+ if !set {
+ log.Fatalf("GITHUB_TOKEN is not set in env")
+ }
+ gh.Client = github.NewClient(nil).WithAuthToken(token)
+
+ return gh
+}
diff --git a/contribs/github_bot/comment.go b/contribs/github_bot/comment.go
new file mode 100644
index 000000000000..4aa22b26926c
--- /dev/null
+++ b/contribs/github_bot/comment.go
@@ -0,0 +1,289 @@
+package main
+
+import (
+ "bot/client"
+ "bytes"
+ "fmt"
+ "os"
+ "regexp"
+ "text/template"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/sethvargo/go-githubactions"
+)
+
+type AutoContent struct {
+ Description string
+ Satisfied bool
+ ConditionDetails string
+ RequirementDetails string
+}
+type ManualContent struct {
+ Description string
+ ConditionDetails string
+ CheckedBy string
+ Teams []string
+}
+
+type CommentContent struct {
+ AutoRules []AutoContent
+ ManualRules []ManualContent
+}
+
+// getCommentManualChecks parses the bot comment to get both the check
+// description and the username who checked it
+func getCommentManualChecks(gh *client.GitHub, commentBody string) map[string][2]string {
+ checks := make(map[string][2]string)
+
+ reg := regexp.MustCompile(`(?m:^- \[([ x])\] (.+) \(checked by @(\w+)\)$)`)
+ matches := reg.FindAllStringSubmatch(commentBody, -1)
+
+ gh.Logger.Infof("LOG", matches)
+ for _, match := range matches {
+ checks[match[2]] = [2]string{match[1], match[3]}
+ }
+
+ return checks
+}
+
+// This function checks if :
+// - the current run was triggered by GitHub Actions
+// - the triggering event is an edit of the bot comment
+// - the comment was not edited by the bot itself (prevent infinite loop)
+// - the comment change is only a checkbox being checked or unckecked (or restore)
+// - the actor / comment editor has permission to modify this checkbox (or restore)
+func handleCommentUpdate(gh *client.GitHub) {
+ // 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
+ }
+
+ // Ignore if it's not an comment related event
+ if actionCtx.EventName != "issue_comment" {
+ gh.Logger.Debugf("Event is not issue comment related : %s", actionCtx.EventName)
+ return
+ }
+
+ // Ignore if action type is not deleted or edited
+ actionType, ok := actionCtx.Event["action"].(string)
+ if !ok {
+ gh.Logger.Errorf("Unable to get type on issue comment event")
+ os.Exit(1)
+ }
+
+ if actionType != "deleted" && actionType != "edited" {
+ return
+ }
+
+ // Exit if comment was edited by bot (current authenticated user)
+ authUser, _, err := gh.Client.Users.Get(gh.Ctx, "")
+ if err != nil {
+ gh.Logger.Errorf("Unable to get authenticated user : %v", err)
+ os.Exit(1)
+ }
+
+ if actionCtx.Actor == authUser.GetLogin() {
+ gh.Logger.Debugf("Prevent infinite loop if the bot comment was edited by the bot itself")
+ os.Exit(0)
+ }
+
+ // Ignore if edited comment author is not the bot
+ comment, ok := actionCtx.Event["comment"].(map[string]any)
+ if !ok {
+ gh.Logger.Errorf("Unable to get comment on issue comment event")
+ os.Exit(1)
+ }
+
+ author, ok := comment["user"].(map[string]any)
+ if !ok {
+ gh.Logger.Errorf("Unable to get comment user on issue comment event")
+ os.Exit(1)
+ }
+
+ login, ok := author["login"].(string)
+ if !ok {
+ gh.Logger.Errorf("Unable to get comment user login on issue comment event")
+ os.Exit(1)
+ }
+
+ if login != authUser.GetLogin() {
+ return
+ }
+
+ // Get comment current body
+ current, ok := comment["body"].(string)
+ if !ok {
+ gh.Logger.Errorf("Unable to get comment body on issue comment event")
+ os.Exit(1)
+ }
+
+ // Get comment updated body
+ changes, ok := actionCtx.Event["changes"].(map[string]any)
+ if !ok {
+ gh.Logger.Errorf("Unable to get changes on issue comment event")
+ os.Exit(1)
+ }
+
+ changesBody, ok := changes["body"].(map[string]any)
+ if !ok {
+ gh.Logger.Errorf("Unable to get changes body on issue comment event")
+ os.Exit(1)
+ }
+
+ previous, ok := changesBody["from"].(string)
+ if !ok {
+ gh.Logger.Errorf("Unable to get changes body content on issue comment event")
+ os.Exit(1)
+ }
+
+ // Get PR number from GitHub Actions context
+ issue, ok := actionCtx.Event["issue"].(map[string]any)
+ if !ok {
+ gh.Logger.Errorf("Unable to get issue on issue comment event")
+ os.Exit(1)
+ }
+
+ num, ok := issue["number"].(float64)
+ if !ok || num <= 0 {
+ gh.Logger.Errorf("Unable to get issue number on issue comment event")
+ os.Exit(1)
+ }
+
+ // Check if change is only a checkbox being checked or unckecked
+ checkboxes := regexp.MustCompile(`(?m:^- \[[ x]\])`)
+ if checkboxes.ReplaceAllString(current, "") != checkboxes.ReplaceAllString(previous, "") {
+ // If not, restore previous comment body
+ gh.Logger.Errorf("Bot comment edited outside of checkboxes")
+ gh.SetBotComment(previous, int(num))
+ os.Exit(1)
+ }
+
+ // Check if actor / comment editor has permission to modify changed boxes
+ currentChecks := getCommentManualChecks(gh, current)
+ previousChecks := getCommentManualChecks(gh, previous)
+ edited := ""
+ for key := range currentChecks {
+ if currentChecks[key][0] != previousChecks[key][0] {
+ // Get teams allowed to edit this box from config
+ var teams []string
+ found := false
+ _, manualRules := config(gh)
+
+ for _, manualRule := range manualRules {
+ if manualRule.Description == key {
+ found = true
+ teams = manualRule.Teams
+ }
+ }
+
+ // If rule were not found, return to reprocess the bot comment entirely
+ // (maybe bot config was updated since last run?)
+ if !found {
+ gh.Logger.Debugf("Updated rule not found in config : %s", key)
+ return
+ }
+
+ // If teams specified in rule, check if actor is a member of one of them
+ if len(teams) > 0 {
+ found = false
+ for _, team := range teams {
+ for _, member := range gh.ListTeamMembers(team) {
+ if member.GetLogin() == actionCtx.Actor {
+ found = true
+ break
+ }
+ }
+ if found {
+ break
+ }
+ }
+
+ // If not, restore previous comment body
+ if !found {
+ gh.Logger.Errorf("Checkbox edited by a user not allowed to")
+ gh.SetBotComment(previous, int(num))
+ os.Exit(1)
+ }
+ }
+
+ // If box was checked
+ reg := regexp.MustCompile(fmt.Sprintf("(?m:^- [%s] %s.*$)", currentChecks[key][0], key))
+ if currentChecks[key][0] == "x" {
+ edited = reg.ReplaceAllString(
+ current,
+ fmt.Sprintf("- [%s] %s (checked by @%s)", currentChecks[key][0], key, currentChecks[key][1]),
+ )
+ } else {
+ edited = reg.ReplaceAllString(
+ current,
+ fmt.Sprintf("- [%s] %s", currentChecks[key][0], key),
+ )
+ }
+ }
+ }
+
+ // Update comment then exit
+ if edited != "" {
+ gh.SetBotComment(edited, int(num))
+ gh.Logger.Debugf("Comment manual checks updated successfuly")
+ os.Exit(0)
+ }
+}
+
+func updateComment(gh *client.GitHub, pr *github.PullRequest, content CommentContent) {
+ // Create bot comment using template file
+ const tmplFile = "comment.tmpl"
+ tmpl, err := template.New(tmplFile).ParseFiles(tmplFile)
+ if err != nil {
+ panic(err)
+ }
+
+ var commentBytes bytes.Buffer
+ if err := tmpl.Execute(&commentBytes, content); err != nil {
+ panic(err)
+ }
+
+ // Create commit status
+ var (
+ comment = gh.SetBotComment(commentBytes.String(), pr.GetNumber())
+ context = "Merge Requirements"
+ targetURL = comment.GetHTMLURL()
+ state = "pending"
+ description = "Some requirements are not satisfied yet. See bot comment."
+ allSatisfied = true
+ )
+
+ // Check if every requirements are satisfied
+ for _, auto := range content.AutoRules {
+ if !auto.Satisfied {
+ allSatisfied = false
+ }
+ }
+
+ for _, manual := range content.ManualRules {
+ if manual.CheckedBy == "" {
+ allSatisfied = false
+ }
+ }
+
+ if allSatisfied {
+ state = "success"
+ description = "All requirements are satisfied."
+ }
+
+ if _, _, err := gh.Client.Repositories.CreateStatus(
+ gh.Ctx,
+ gh.Owner,
+ gh.Repo,
+ pr.GetHead().GetSHA(),
+ &github.RepoStatus{
+ Context: &context,
+ State: &state,
+ TargetURL: &targetURL,
+ Description: &description,
+ }); err != nil {
+ gh.Logger.Errorf("Unable to create status on PR %d : %v", pr.GetNumber(), err)
+ }
+}
diff --git a/contribs/github_bot/comment.tmpl b/contribs/github_bot/comment.tmpl
new file mode 100644
index 000000000000..cd66795df9ab
--- /dev/null
+++ b/contribs/github_bot/comment.tmpl
@@ -0,0 +1,49 @@
+# Merge Requirements
+
+The following requirements must be fulfilled before a pull request can be merged.
+Some requirement checks are automated and can be verified by the CI, while others need manual verification by a staff member.
+
+These requirements are defined in this [config file](https://github.com/gnolang/gno/blob/master/misc/github-bot/config.go).
+
+## Automated Checks
+
+{{ range .AutoRules }} {{ if .Satisfied }}🟢{{ else }}🟠{{ end }} {{ .Description }}
+{{ end }}
+
+Details
+{{ range .AutoRules }}
+{{ .Description }}
+
+### If :
+```
+{{ .ConditionDetails }}
+```
+### Then :
+```
+{{ .RequirementDetails }}
+```
+
+{{ end }}
+
+
+## Manual Checks
+
+{{ range .ManualRules }}- [{{ if .CheckedBy }}x{{ else }} {{ end }}] {{ .Description }}{{ if .CheckedBy }} (checked by @{{ .CheckedBy }}){{ end }}
+{{ end }}
+
+Details
+{{ range .ManualRules }}
+{{ .Description }}
+
+### If :
+```
+{{ .ConditionDetails }}
+```
+### Can be checked by :
+{{range $item := .Teams }} - team {{ $item }}
+{{ else }}
+- Any user with comment edit permission
+{{end}}
+
+{{ end }}
+
diff --git a/contribs/github_bot/condition/assignee.go b/contribs/github_bot/condition/assignee.go
new file mode 100644
index 000000000000..b1e9debb261b
--- /dev/null
+++ b/contribs/github_bot/condition/assignee.go
@@ -0,0 +1,59 @@
+package condition
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Assignee Condition
+type assignee struct {
+ user string
+}
+
+var _ Condition = &assignee{}
+
+func (a *assignee) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("A pull request assignee is user : %s", a.user)
+
+ for _, assignee := range pr.Assignees {
+ if a.user == assignee.GetLogin() {
+ return utils.AddStatusNode(true, detail, details)
+ }
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func Assignee(user string) Condition {
+ return &assignee{user: user}
+}
+
+// AssigneeInTeam Condition
+type assigneeInTeam struct {
+ gh *client.GitHub
+ team string
+}
+
+var _ Condition = &assigneeInTeam{}
+
+func (a *assigneeInTeam) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("A pull request assignee is a member of the team : %s", a.team)
+
+ for _, member := range a.gh.ListTeamMembers(a.team) {
+ for _, assignee := range pr.Assignees {
+ if member.GetLogin() == assignee.GetLogin() {
+ return utils.AddStatusNode(true, fmt.Sprintf("%s (member : %s)", detail, member.GetLogin()), details)
+ }
+ }
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func AssigneeInTeam(gh *client.GitHub, team string) Condition {
+ return &assigneeInTeam{gh: gh, team: team}
+}
diff --git a/contribs/github_bot/condition/author.go b/contribs/github_bot/condition/author.go
new file mode 100644
index 000000000000..be2b293e27ed
--- /dev/null
+++ b/contribs/github_bot/condition/author.go
@@ -0,0 +1,53 @@
+package condition
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Author Condition
+type author struct {
+ user string
+}
+
+var _ Condition = &author{}
+
+func (a *author) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(
+ a.user == pr.GetUser().GetLogin(),
+ fmt.Sprintf("Pull request author is user : %v", a.user),
+ details,
+ )
+}
+
+func Author(user string) Condition {
+ return &author{user: user}
+}
+
+// AuthorInTeam Condition
+type authorInTeam struct {
+ gh *client.GitHub
+ team string
+}
+
+var _ Condition = &authorInTeam{}
+
+func (a *authorInTeam) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("Pull request author is a member of the team : %s", a.team)
+
+ for _, member := range a.gh.ListTeamMembers(a.team) {
+ if member.GetLogin() == pr.GetUser().GetLogin() {
+ return utils.AddStatusNode(true, detail, details)
+ }
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func AuthorInTeam(gh *client.GitHub, team string) Condition {
+ return &authorInTeam{gh: gh, team: team}
+}
diff --git a/contribs/github_bot/condition/boolean.go b/contribs/github_bot/condition/boolean.go
new file mode 100644
index 000000000000..db9d1fb45dd3
--- /dev/null
+++ b/contribs/github_bot/condition/boolean.go
@@ -0,0 +1,100 @@
+package condition
+
+import (
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// And Condition
+type and struct {
+ conditions []Condition
+}
+
+var _ Condition = &and{}
+
+func (a *and) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ met := true
+ branch := details.AddBranch("")
+
+ for _, condition := range a.conditions {
+ if !condition.IsMet(pr, branch) {
+ met = false
+ }
+ }
+
+ if met {
+ branch.SetValue("🟢 And")
+ } else {
+ branch.SetValue("🔴 And")
+ }
+
+ return met
+}
+
+func And(conditions ...Condition) Condition {
+ if len(conditions) < 2 {
+ panic("You should pass at least 2 conditions to And()")
+ }
+
+ return &and{conditions}
+}
+
+// Or Condition
+type or struct {
+ conditions []Condition
+}
+
+var _ Condition = &or{}
+
+func (o *or) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ met := false
+ branch := details.AddBranch("")
+
+ for _, condition := range o.conditions {
+ if condition.IsMet(pr, branch) {
+ met = true
+ }
+ }
+
+ if met {
+ branch.SetValue("🟢 Or")
+ } else {
+ branch.SetValue("🔴 Or")
+ }
+
+ return met
+}
+
+func Or(conditions ...Condition) Condition {
+ if len(conditions) < 2 {
+ panic("You should pass at least 2 conditions to Or()")
+ }
+
+ return &or{conditions}
+}
+
+// Not Condition
+type not struct {
+ cond Condition
+}
+
+var _ Condition = ¬{}
+
+func (n *not) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ met := n.cond.IsMet(pr, details)
+ node := details.FindLastNode()
+
+ if met {
+ node.SetValue(fmt.Sprintf("🔴 Not (%s)", node.(*treeprint.Node).Value.(string)))
+ } else {
+ node.SetValue(fmt.Sprintf("🟢 Not (%s)", node.(*treeprint.Node).Value.(string)))
+ }
+
+ return !met
+}
+
+func Not(cond Condition) Condition {
+ return ¬{cond}
+}
diff --git a/contribs/github_bot/condition/branch.go b/contribs/github_bot/condition/branch.go
new file mode 100644
index 000000000000..bfb0dd78d3aa
--- /dev/null
+++ b/contribs/github_bot/condition/branch.go
@@ -0,0 +1,48 @@
+package condition
+
+import (
+ "bot/utils"
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// BaseBranch Condition
+type baseBranch struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &baseBranch{}
+
+func (b *baseBranch) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(
+ b.pattern.MatchString(pr.GetBase().GetRef()),
+ fmt.Sprintf("The base branch match this pattern : %s", b.pattern.String()),
+ details,
+ )
+}
+
+func BaseBranch(pattern string) Condition {
+ return &baseBranch{pattern: regexp.MustCompile(pattern)}
+}
+
+// HeadBranch Condition
+type headBranch struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &headBranch{}
+
+func (h *headBranch) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(
+ h.pattern.MatchString(pr.GetHead().GetRef()),
+ fmt.Sprintf("The head branch match this pattern : %s", h.pattern.String()),
+ details,
+ )
+}
+
+func HeadBranch(pattern string) Condition {
+ return &headBranch{pattern: regexp.MustCompile(pattern)}
+}
diff --git a/contribs/github_bot/condition/condition.go b/contribs/github_bot/condition/condition.go
new file mode 100644
index 000000000000..9dce8ea1a704
--- /dev/null
+++ b/contribs/github_bot/condition/condition.go
@@ -0,0 +1,12 @@
+package condition
+
+import (
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+type Condition interface {
+ // Check if the Condition is met and add the detail
+ // to the tree passed as a parameter
+ IsMet(pr *github.PullRequest, details treeprint.Tree) bool
+}
diff --git a/contribs/github_bot/condition/constant.go b/contribs/github_bot/condition/constant.go
new file mode 100644
index 000000000000..aa6738755834
--- /dev/null
+++ b/contribs/github_bot/condition/constant.go
@@ -0,0 +1,34 @@
+package condition
+
+import (
+ "bot/utils"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Always Condition
+type always struct{}
+
+var _ Condition = &always{}
+
+func (*always) IsMet(_ *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(true, "On every pull request", details)
+}
+
+func Always() Condition {
+ return &always{}
+}
+
+// Never Condition
+type never struct{}
+
+var _ Condition = &never{}
+
+func (*never) IsMet(_ *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(false, "On no pull request", details)
+}
+
+func Never() Condition {
+ return &never{}
+}
diff --git a/contribs/github_bot/condition/file.go b/contribs/github_bot/condition/file.go
new file mode 100644
index 000000000000..71be92e6eddd
--- /dev/null
+++ b/contribs/github_bot/condition/file.go
@@ -0,0 +1,57 @@
+package condition
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// FileChanged Condition
+type fileChanged struct {
+ gh *client.GitHub
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &fileChanged{}
+
+func (fc *fileChanged) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("A changed file match this pattern : %s", fc.pattern.String())
+ opts := &github.ListOptions{
+ PerPage: client.PageSize,
+ }
+
+ for {
+ files, response, err := fc.gh.Client.PullRequests.ListFiles(
+ fc.gh.Ctx,
+ fc.gh.Owner,
+ fc.gh.Repo,
+ pr.GetNumber(),
+ opts,
+ )
+ if err != nil {
+ fc.gh.Logger.Errorf("Unable to list changed files for PR %d : %v", pr.GetNumber(), err)
+ break
+ }
+
+ for _, file := range files {
+ if fc.pattern.MatchString(file.GetFilename()) {
+ return utils.AddStatusNode(true, fmt.Sprintf("%s (filename : %s)", detail, file.GetFilename()), details)
+ }
+ }
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func FileChanged(gh *client.GitHub, pattern string) Condition {
+ return &fileChanged{gh: gh, pattern: regexp.MustCompile(pattern)}
+}
diff --git a/contribs/github_bot/condition/label.go b/contribs/github_bot/condition/label.go
new file mode 100644
index 000000000000..c346002d0514
--- /dev/null
+++ b/contribs/github_bot/condition/label.go
@@ -0,0 +1,33 @@
+package condition
+
+import (
+ "bot/utils"
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Label Condition
+type label struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &label{}
+
+func (l *label) IsMet(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("A label match this pattern : %s", l.pattern.String())
+
+ for _, label := range pr.Labels {
+ if l.pattern.MatchString(label.GetName()) {
+ return utils.AddStatusNode(true, fmt.Sprintf("%s (label : %s)", detail, label.GetName()), details)
+ }
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func Label(pattern string) Condition {
+ return &label{pattern: regexp.MustCompile(pattern)}
+}
diff --git a/contribs/github_bot/config.go b/contribs/github_bot/config.go
new file mode 100644
index 000000000000..92e3b23dd126
--- /dev/null
+++ b/contribs/github_bot/config.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "bot/client"
+ c "bot/condition"
+ r "bot/requirement"
+)
+
+type automaticCheck struct {
+ Description string
+ If c.Condition
+ Then r.Requirement
+}
+
+type manualCheck struct {
+ Description string
+ If c.Condition
+ Teams []string
+}
+
+func config(gh *client.GitHub) ([]automaticCheck, []manualCheck) {
+ return []automaticCheck{
+ {
+ Description: "Changes on '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"),
+ ),
+ ),
+ },
+ {
+ Description: "Maintainer must be able to edit this pull request",
+ If: c.And(
+ c.Always(),
+ c.Not(c.Never()),
+ c.Or(
+ c.FileChanged(gh, ".github"),
+ c.FileChanged(gh, ".*"),
+ c.FileChanged(gh, "bot"),
+ c.FileChanged(gh, ".*.yml"),
+ ),
+ ),
+ Then: r.MaintainerCanModify(),
+ },
+ {
+ Description: "Dumb test",
+ If: c.Not(c.HeadBranch("toto")),
+ Then: r.Label(gh, "bug"),
+ },
+ }, []manualCheck{
+ {
+ Description: "Manual check #1",
+ If: c.And(
+ c.Always(),
+ c.Not(c.Never()),
+ c.Or(
+ c.FileChanged(gh, ".github"),
+ c.FileChanged(gh, ".*"),
+ c.FileChanged(gh, "bot"),
+ c.FileChanged(gh, ".*.yml"),
+ ),
+ ),
+ Teams: []string{"Toto", "Tutu"},
+ },
+ {
+ Description: "Manual check #2",
+ If: c.And(
+ c.Always(),
+ c.Not(c.Never()),
+ c.Or(
+ c.FileChanged(gh, ".github"),
+ c.FileChanged(gh, ".*"),
+ c.FileChanged(gh, "bot"),
+ c.FileChanged(gh, ".*.yml"),
+ ),
+ ),
+ Teams: []string{"Toto", "Tutu"},
+ },
+ {
+ Description: "Manual check #3",
+ If: c.And(
+ c.Always(),
+ c.Not(c.Never()),
+ c.Or(
+ c.FileChanged(gh, ".github"),
+ c.FileChanged(gh, ".*"),
+ c.FileChanged(gh, "bot"),
+ c.FileChanged(gh, ".*.yml"),
+ ),
+ ),
+ },
+ {
+ Description: "Manual check #4",
+ If: c.And(
+ c.Always(),
+ c.Not(c.Never()),
+ c.Or(
+ c.FileChanged(gh, ".github"),
+ c.FileChanged(gh, "bot"),
+ c.FileChanged(gh, ".*.yml"),
+ ),
+ ),
+ Teams: []string{"Toto", "Tutu"},
+ },
+ }
+}
diff --git a/contribs/github_bot/go.mod b/contribs/github_bot/go.mod
new file mode 100644
index 000000000000..32ddb2b2cb28
--- /dev/null
+++ b/contribs/github_bot/go.mod
@@ -0,0 +1,11 @@
+module bot
+
+go 1.22.2
+
+require (
+ github.com/google/go-github/v66 v66.0.0
+ github.com/sethvargo/go-githubactions v1.3.0
+ github.com/xlab/treeprint v1.2.0
+)
+
+require github.com/google/go-querystring v1.1.0 // indirect
diff --git a/contribs/github_bot/go.sum b/contribs/github_bot/go.sum
new file mode 100644
index 000000000000..5e2d8a93984f
--- /dev/null
+++ b/contribs/github_bot/go.sum
@@ -0,0 +1,22 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
+github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sethvargo/go-githubactions v1.3.0 h1:Kg633LIUV2IrJsqy2MfveiED/Ouo+H2P0itWS0eLh8A=
+github.com/sethvargo/go-githubactions v1.3.0/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
+github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/contribs/github_bot/logger/action.go b/contribs/github_bot/logger/action.go
new file mode 100644
index 000000000000..c6d10429e626
--- /dev/null
+++ b/contribs/github_bot/logger/action.go
@@ -0,0 +1,43 @@
+package logger
+
+import (
+ "github.com/sethvargo/go-githubactions"
+)
+
+type actionLogger struct{}
+
+var _ Logger = &actionLogger{}
+
+// Debugf implements Logger.
+func (a *actionLogger) Debugf(msg string, args ...any) {
+ githubactions.Debugf(msg, args...)
+}
+
+// Errorf implements Logger.
+func (a *actionLogger) Errorf(msg string, args ...any) {
+ githubactions.Errorf(msg, args...)
+}
+
+// Fatalf implements Logger.
+func (a *actionLogger) Fatalf(msg string, args ...any) {
+ githubactions.Fatalf(msg, args...)
+}
+
+// Infof implements Logger.
+func (a *actionLogger) Infof(msg string, args ...any) {
+ githubactions.Infof(msg, args...)
+}
+
+// Noticef implements Logger.
+func (a *actionLogger) Noticef(msg string, args ...any) {
+ githubactions.Noticef(msg, args...)
+}
+
+// Warningf implements Logger.
+func (a *actionLogger) Warningf(msg string, args ...any) {
+ githubactions.Warningf(msg, args...)
+}
+
+func newActionLogger() Logger {
+ return &actionLogger{}
+}
diff --git a/contribs/github_bot/logger/logger.go b/contribs/github_bot/logger/logger.go
new file mode 100644
index 000000000000..53b50c6ed9ab
--- /dev/null
+++ b/contribs/github_bot/logger/logger.go
@@ -0,0 +1,34 @@
+package logger
+
+import (
+ "os"
+)
+
+// All Logger methods follow the standard fmt.Printf convention
+type Logger interface {
+ // Debugf prints a debug-level message
+ Debugf(msg string, args ...any)
+
+ // Noticef prints a notice-level message
+ Noticef(msg string, args ...any)
+
+ // Warningf prints a warning-level message
+ Warningf(msg string, args ...any)
+
+ // Errorf prints a error-level message
+ Errorf(msg string, args ...any)
+
+ // Fatalf prints a error-level message and exits
+ Fatalf(msg string, args ...any)
+
+ // Infof prints message to stdout without any level annotations
+ Infof(msg string, args ...any)
+}
+
+func NewLogger(verbose bool) Logger {
+ if _, isAction := os.LookupEnv("GITHUB_ACTION"); isAction {
+ return newActionLogger()
+ }
+
+ return newTermLogger(verbose)
+}
diff --git a/contribs/github_bot/logger/terminal.go b/contribs/github_bot/logger/terminal.go
new file mode 100644
index 000000000000..cc12022011a4
--- /dev/null
+++ b/contribs/github_bot/logger/terminal.go
@@ -0,0 +1,55 @@
+package logger
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+)
+
+type termLogger struct{}
+
+var _ Logger = &termLogger{}
+
+// Debugf implements Logger
+func (s *termLogger) Debugf(msg string, args ...any) {
+ msg = fmt.Sprintf("%s\n", msg)
+ slog.Debug(fmt.Sprintf(msg, args...))
+}
+
+// Errorf implements Logger
+func (s *termLogger) Errorf(msg string, args ...any) {
+ msg = fmt.Sprintf("%s\n", msg)
+ slog.Error(fmt.Sprintf(msg, args...))
+}
+
+// Fatalf implements Logger
+func (s *termLogger) Fatalf(msg string, args ...any) {
+ s.Errorf(msg, args...)
+ os.Exit(1)
+}
+
+// Infof implements Logger
+func (s *termLogger) Infof(msg string, args ...any) {
+ msg = fmt.Sprintf("%s\n", msg)
+ slog.Info(fmt.Sprintf(msg, args...))
+}
+
+// Noticef implements Logger
+func (s *termLogger) Noticef(msg string, args ...any) {
+ // Alias to info on terminal since notice level only exists on GitHub Actions
+ s.Infof(msg, args...)
+}
+
+// Warningf implements Logger
+func (s *termLogger) Warningf(msg string, args ...any) {
+ msg = fmt.Sprintf("%s\n", msg)
+ slog.Warn(fmt.Sprintf(msg, args...))
+}
+
+func newTermLogger(verbose bool) Logger {
+ if verbose {
+ slog.SetLogLoggerLevel(slog.LevelDebug)
+ }
+
+ return &termLogger{}
+}
diff --git a/contribs/github_bot/main.go b/contribs/github_bot/main.go
new file mode 100644
index 000000000000..1a221e11bcdb
--- /dev/null
+++ b/contribs/github_bot/main.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "bot/client"
+ "bot/param"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+func main() {
+ // Get params by parsing CLI flags and/or GitHub Actions context
+ params := param.Get()
+
+ // Init GitHub API client
+ gh := client.New(params)
+
+ // Handle comment change if any
+ handleCommentUpdate(gh)
+
+ // Get a slice of pull requests to process
+ var (
+ prs []*github.PullRequest
+ err error
+ )
+
+ // If requested, get all opened pull requests
+ if params.PrAll {
+ opts := &github.PullRequestListOptions{
+ State: "open",
+ Sort: "updated",
+ Direction: "desc",
+ }
+
+ prs, _, err = gh.Client.PullRequests.List(gh.Ctx, gh.Owner, gh.Repo, opts)
+ if err != nil {
+ gh.Logger.Fatalf("Unable to get all opened pull requests : %v", err)
+ }
+
+ // Or get only specified pull request(s) (flag or GitHub Action context)
+ } else {
+ 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 {
+ gh.Logger.Fatalf("Unable to get specified pull request (%d) : %v", prNum, err)
+ }
+ prs[i] = pr
+ }
+ }
+
+ // Process all pull requests
+ autoRules, manualRules := config(gh)
+ for _, pr := range prs {
+ commentContent := CommentContent{}
+
+ // Iterate over all automatic rules in config
+ for _, autoRule := range autoRules {
+ ifDetails := treeprint.NewWithRoot("🟢 Condition met")
+
+ // Check if condition of this rule are met by this PR
+ if autoRule.If.IsMet(pr, ifDetails) {
+ c := AutoContent{Description: autoRule.Description, Satisfied: false}
+ thenDetails := treeprint.NewWithRoot("🔴 Requirement not satisfied")
+
+ // Check if requirement of this rule are satisfied by this PR
+ if autoRule.Then.IsSatisfied(pr, thenDetails) {
+ thenDetails.SetValue("🟢 Requirement satisfied")
+ c.Satisfied = true
+ }
+
+ c.ConditionDetails = ifDetails.String()
+ c.RequirementDetails = thenDetails.String()
+ commentContent.AutoRules = append(commentContent.AutoRules, c)
+ }
+ }
+
+ // Iterate over all manual rules in config
+ for _, manualRule := range manualRules {
+ ifDetails := treeprint.NewWithRoot("🟢 Condition met")
+
+ // Get manual checks states
+ checks := make(map[string][2]string)
+ if comment := gh.GetBotComment(pr.GetNumber()); comment != nil {
+ checks = getCommentManualChecks(gh, comment.GetBody())
+ }
+
+ // Check if condition of this rule are met by this PR
+ if manualRule.If.IsMet(pr, ifDetails) {
+ commentContent.ManualRules = append(
+ commentContent.ManualRules,
+ ManualContent{
+ Description: manualRule.Description,
+ ConditionDetails: ifDetails.String(),
+ CheckedBy: checks[manualRule.Description][1],
+ Teams: manualRule.Teams,
+ },
+ )
+ }
+ }
+
+ // Print results in PR comment or in logs
+ if gh.DryRun {
+ // TODO: Pretty print dry run
+ } else {
+ updateComment(gh, pr, commentContent)
+ }
+ }
+}
diff --git a/contribs/github_bot/param/param.go b/contribs/github_bot/param/param.go
new file mode 100644
index 000000000000..ea6af698ca3e
--- /dev/null
+++ b/contribs/github_bot/param/param.go
@@ -0,0 +1,109 @@
+package param
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/sethvargo/go-githubactions"
+)
+
+type Params struct {
+ Owner string
+ Repo string
+ PrAll bool
+ PrNums PrList
+ Verbose bool
+ DryRun bool
+ Timeout uint
+}
+
+// Get Params from both cli flags and/or GitHub Actions context
+func Get() Params {
+ p := Params{}
+
+ // Add cmd description to usage message
+ flag.Usage = func() {
+ fmt.Fprint(flag.CommandLine.Output(), "This tool checks if requirements for a PR to be merged are satisfied (defined in config.go) and display PR status checks accordingly.\n")
+ fmt.Fprint(flag.CommandLine.Output(), "A valid GitHub Token must be provided by setting the GITHUB_TOKEN env variable.\n\n")
+ flag.PrintDefaults()
+ }
+
+ // Helper to display an error + usage message before exiting
+ errorUsage := func(error string) {
+ fmt.Fprintf(flag.CommandLine.Output(), "Error : %s\n\n", error)
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ // Flags definition
+ flag.StringVar(&p.Owner, "owner", "", "owner of the repo to process, if empty, will be retrieved from GitHub Actions context")
+ flag.StringVar(&p.Repo, "repo", "", "repo to process, if empty, will be retrieved from GitHub Actions context")
+ flag.BoolVar(&p.PrAll, "pr-all", false, "process all opened pull requests")
+ flag.TextVar(&p.PrNums, "pr-numbers", PrList(nil), "pull request(s) to process, must be a comma seperated list of PR numbers, e.g '42,1337,7890'. If empty, will be retrived from GitHub Actions context")
+ flag.BoolVar(&p.Verbose, "verbose", false, "set logging level to debug")
+ flag.BoolVar(&p.DryRun, "dry-run", false, "print if pull request requirements are met without updating PR checks on GitHub web interface")
+ flag.UintVar(&p.Timeout, "timeout", 0, "timeout in milliseconds")
+ flag.Parse()
+
+ // If any arg remain after flags processing
+ if len(flag.Args()) > 0 {
+ errorUsage(fmt.Sprintf("Unknown arg(s) provided : %v", flag.Args()))
+ }
+
+ // Check if flags are coherents
+ if p.PrAll && len(p.PrNums) != 0 {
+ errorUsage("You can specify only one of the '-pr-all' and '-pr-numbers' flags")
+ }
+
+ // If one of these values is empty, it must be retrieved
+ // from GitHub Actions context
+ if p.Owner == "" || p.Repo == "" || (len(p.PrNums) == 0 && !p.PrAll) {
+ actionCtx, err := githubactions.Context()
+ if err != nil {
+ errorUsage(fmt.Sprintf("Unable to get GitHub Actions context : %v", err))
+ }
+
+ if p.Owner == "" {
+ if p.Owner, _ = actionCtx.Repo(); p.Owner == "" {
+ errorUsage("Unable to retrieve owner from GitHub Actions context, you may want to set it using -onwer flag")
+ }
+ }
+ if p.Repo == "" {
+ if _, p.Repo = actionCtx.Repo(); p.Repo == "" {
+ errorUsage("Unable to retrieve repo from GitHub Actions context, you may want to set it using -repo flag")
+ }
+ }
+ if len(p.PrNums) == 0 && !p.PrAll {
+ const errMsg = "Unable to retrieve pull request number from GitHub Actions context, you may want to set it using -pr-numbers flag"
+ var num float64
+
+ switch actionCtx.EventName {
+ case "issue_comment":
+ issue, ok := actionCtx.Event["issue"].(map[string]any)
+ if !ok {
+ errorUsage(errMsg)
+ }
+ num, ok = issue["number"].(float64)
+ if !ok || num <= 0 {
+ errorUsage(errMsg)
+ }
+ case "pull_request":
+ pr, ok := actionCtx.Event["pull_request"].(map[string]any)
+ if !ok {
+ errorUsage(errMsg)
+ }
+ num, ok = pr["number"].(float64)
+ if !ok || num <= 0 {
+ errorUsage(errMsg)
+ }
+ default:
+ errorUsage(errMsg)
+ }
+
+ p.PrNums = PrList([]int{int(num)})
+ }
+ }
+
+ return p
+}
diff --git a/contribs/github_bot/param/prlist.go b/contribs/github_bot/param/prlist.go
new file mode 100644
index 000000000000..96a04ebce14c
--- /dev/null
+++ b/contribs/github_bot/param/prlist.go
@@ -0,0 +1,45 @@
+package param
+
+import (
+ "encoding"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type PrList []int
+
+// PrList is both a TextMarshaler and a TextUnmarshaler
+var (
+ _ encoding.TextMarshaler = PrList{}
+ _ encoding.TextUnmarshaler = &PrList{}
+)
+
+// MarshalText implements encoding.TextMarshaler.
+func (p PrList) MarshalText() (text []byte, err error) {
+ prNumsStr := make([]string, len(p))
+
+ for i, prNum := range p {
+ prNumsStr[i] = strconv.Itoa(prNum)
+ }
+
+ return []byte(strings.Join(prNumsStr, ",")), nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (p *PrList) UnmarshalText(text []byte) error {
+ for _, prNumStr := range strings.Split(string(text), ",") {
+ prNum, err := strconv.Atoi(strings.TrimSpace(prNumStr))
+ if err != nil {
+ return err
+ }
+
+ if prNum <= 0 {
+ return fmt.Errorf("invalid pull request number (<= 0) : original(%s) parsed(%d)", prNumStr, prNum)
+ }
+
+ *p = append(*p, prNum)
+ }
+
+ return nil
+}
diff --git a/contribs/github_bot/requirement/assignee.go b/contribs/github_bot/requirement/assignee.go
new file mode 100644
index 000000000000..6854322521a6
--- /dev/null
+++ b/contribs/github_bot/requirement/assignee.go
@@ -0,0 +1,52 @@
+package requirement
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Assignee Requirement
+type assignee struct {
+ gh *client.GitHub
+ user string
+}
+
+var _ Requirement = &assignee{}
+
+func (a *assignee) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("This user is assigned to pull request : %s", a.user)
+
+ // Check if user was already assigned to PR
+ for _, assignee := range pr.Assignees {
+ if a.user == assignee.GetLogin() {
+ return utils.AddStatusNode(true, detail, details)
+ }
+ }
+
+ // If in a dry run, skip assigning the user
+ if a.gh.DryRun {
+ return utils.AddStatusNode(false, detail, details)
+ }
+
+ // If user not already assigned, assign it
+ if _, _, err := a.gh.Client.Issues.AddAssignees(
+ a.gh.Ctx,
+ a.gh.Owner,
+ a.gh.Repo,
+ pr.GetNumber(),
+ []string{a.user},
+ ); err != nil {
+ a.gh.Logger.Errorf("Unable to assign user %s to PR %d : %v", a.user, pr.GetNumber(), err)
+ return utils.AddStatusNode(false, detail, details)
+ }
+
+ return utils.AddStatusNode(true, detail, details)
+}
+
+func Assignee(gh *client.GitHub, user string) Requirement {
+ return &assignee{gh: gh, user: user}
+}
diff --git a/contribs/github_bot/requirement/author.go b/contribs/github_bot/requirement/author.go
new file mode 100644
index 000000000000..29c3f6d14048
--- /dev/null
+++ b/contribs/github_bot/requirement/author.go
@@ -0,0 +1,53 @@
+package requirement
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// AuthorInTeam Requirement
+type author struct {
+ user string
+}
+
+var _ Requirement = &author{}
+
+func (a *author) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(
+ a.user == pr.GetUser().GetLogin(),
+ fmt.Sprintf("Pull request author is user : %v", a.user),
+ details,
+ )
+}
+
+func Author(user string) Requirement {
+ return &author{user: user}
+}
+
+// AuthorInTeam Requirement
+type authorInTeam struct {
+ gh *client.GitHub
+ team string
+}
+
+var _ Requirement = &authorInTeam{}
+
+func (a *authorInTeam) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("Pull request author is a member of the team : %s", a.team)
+
+ for _, member := range a.gh.ListTeamMembers(a.team) {
+ if member.GetLogin() == pr.GetUser().GetLogin() {
+ return utils.AddStatusNode(true, detail, details)
+ }
+ }
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func AuthorInTeam(gh *client.GitHub, team string) Requirement {
+ return &authorInTeam{gh: gh, team: team}
+}
diff --git a/contribs/github_bot/requirement/boolean.go b/contribs/github_bot/requirement/boolean.go
new file mode 100644
index 000000000000..1deff3b0531c
--- /dev/null
+++ b/contribs/github_bot/requirement/boolean.go
@@ -0,0 +1,100 @@
+package requirement
+
+import (
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// And Requirement
+type and struct {
+ requirements []Requirement
+}
+
+var _ Requirement = &and{}
+
+func (a *and) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ satisfied := true
+ branch := details.AddBranch("")
+
+ for _, requirement := range a.requirements {
+ if !requirement.IsSatisfied(pr, branch) {
+ satisfied = false
+ }
+ }
+
+ if satisfied {
+ branch.SetValue("🟢 And")
+ } else {
+ branch.SetValue("🔴 And")
+ }
+
+ return satisfied
+}
+
+func And(requirements ...Requirement) Requirement {
+ if len(requirements) < 2 {
+ panic("You should pass at least 2 requirements to And()")
+ }
+
+ return &and{requirements}
+}
+
+// Or Requirement
+type or struct {
+ requirements []Requirement
+}
+
+var _ Requirement = &or{}
+
+func (o *or) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ satisfied := false
+ branch := details.AddBranch("")
+
+ for _, requirement := range o.requirements {
+ if requirement.IsSatisfied(pr, branch) {
+ satisfied = true
+ }
+ }
+
+ if satisfied {
+ branch.SetValue("🟢 Or")
+ } else {
+ branch.SetValue("🔴 Or")
+ }
+
+ return satisfied
+}
+
+func Or(requirements ...Requirement) Requirement {
+ if len(requirements) < 2 {
+ panic("You should pass at least 2 requirements to Or()")
+ }
+
+ return &or{requirements}
+}
+
+// Not Requirement
+type not struct {
+ req Requirement
+}
+
+var _ Requirement = ¬{}
+
+func (n *not) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ satisfied := n.req.IsSatisfied(pr, details)
+ node := details.FindLastNode()
+
+ if satisfied {
+ node.SetValue(fmt.Sprintf("🔴 Not (%s)", node.(*treeprint.Node).Value.(string)))
+ } else {
+ node.SetValue(fmt.Sprintf("🟢 Not (%s)", node.(*treeprint.Node).Value.(string)))
+ }
+
+ return !satisfied
+}
+
+func Not(req Requirement) Requirement {
+ return ¬{req}
+}
diff --git a/contribs/github_bot/requirement/label.go b/contribs/github_bot/requirement/label.go
new file mode 100644
index 000000000000..c1a0bbd7518e
--- /dev/null
+++ b/contribs/github_bot/requirement/label.go
@@ -0,0 +1,52 @@
+package requirement
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Label Requirement
+type label struct {
+ gh *client.GitHub
+ name string
+}
+
+var _ Requirement = &label{}
+
+func (l *label) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("This label is applied to pull request : %s", l.name)
+
+ // Check if label was already applied to PR
+ for _, label := range pr.Labels {
+ if l.name == label.GetName() {
+ return utils.AddStatusNode(true, detail, details)
+ }
+ }
+
+ // If in a dry run, skip applying the label
+ if l.gh.DryRun {
+ return utils.AddStatusNode(false, detail, details)
+ }
+
+ // If label not already applied, apply it
+ if _, _, err := l.gh.Client.Issues.AddLabelsToIssue(
+ l.gh.Ctx,
+ l.gh.Owner,
+ l.gh.Repo,
+ pr.GetNumber(),
+ []string{l.name},
+ ); err != nil {
+ l.gh.Logger.Errorf("Unable to add label %s to PR %d : %v", l.name, pr.GetNumber(), err)
+ return utils.AddStatusNode(false, detail, details)
+ }
+
+ return utils.AddStatusNode(true, detail, details)
+}
+
+func Label(gh *client.GitHub, name string) Requirement {
+ return &label{gh, name}
+}
diff --git a/contribs/github_bot/requirement/maintainer.go b/contribs/github_bot/requirement/maintainer.go
new file mode 100644
index 000000000000..6d89206ed92b
--- /dev/null
+++ b/contribs/github_bot/requirement/maintainer.go
@@ -0,0 +1,25 @@
+package requirement
+
+import (
+ "bot/utils"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// MaintainerCanModify Requirement
+type maintainerCanModify struct{}
+
+var _ Requirement = &maintainerCanModify{}
+
+func (a *maintainerCanModify) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ return utils.AddStatusNode(
+ pr.GetMaintainerCanModify(),
+ "Maintainer can modify this pull request",
+ details,
+ )
+}
+
+func MaintainerCanModify() Requirement {
+ return &maintainerCanModify{}
+}
diff --git a/contribs/github_bot/requirement/requirement.go b/contribs/github_bot/requirement/requirement.go
new file mode 100644
index 000000000000..ae48a1e96485
--- /dev/null
+++ b/contribs/github_bot/requirement/requirement.go
@@ -0,0 +1,12 @@
+package requirement
+
+import (
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+type Requirement interface {
+ // Check if the Requirement is satisfied and add the detail
+ // to the tree passed as a parameter
+ IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool
+}
diff --git a/contribs/github_bot/requirement/reviewer.go b/contribs/github_bot/requirement/reviewer.go
new file mode 100644
index 000000000000..ce6e46becdb8
--- /dev/null
+++ b/contribs/github_bot/requirement/reviewer.go
@@ -0,0 +1,130 @@
+package requirement
+
+import (
+ "bot/client"
+ "bot/utils"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+ "github.com/xlab/treeprint"
+)
+
+// Reviewer Requirement
+type reviewByUser struct {
+ gh *client.GitHub
+ user string
+}
+
+var _ Requirement = &reviewByUser{}
+
+func (r *reviewByUser) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("This user approved pull request : %s", r.user)
+
+ // If not a dry run, make the user a reviewer if he's not already
+ if !r.gh.DryRun {
+ requested := false
+ if reviewers := r.gh.ListPrReviewers(pr.GetNumber()); reviewers != nil {
+ for _, user := range reviewers.Users {
+ if user.GetLogin() == r.user {
+ requested = true
+ break
+ }
+ }
+ }
+
+ if requested {
+ r.gh.Logger.Debugf("Review of user %s already requested on PR %d", r.user, pr.GetNumber())
+ } else {
+ r.gh.Logger.Debugf("Requesting review from user %s on PR %d", r.user, pr.GetNumber())
+ if _, _, err := r.gh.Client.PullRequests.RequestReviewers(
+ r.gh.Ctx,
+ r.gh.Owner,
+ r.gh.Repo,
+ pr.GetNumber(),
+ github.ReviewersRequest{
+ Reviewers: []string{r.user},
+ },
+ ); err != nil {
+ r.gh.Logger.Errorf("Unable to request review from user %s on PR %d : %v", r.user, pr.GetNumber(), err)
+ }
+ }
+ }
+
+ // Check if user already approved this PR
+ for _, review := range r.gh.ListPrReviews(pr.GetNumber()) {
+ if review.GetUser().GetLogin() == r.user {
+ r.gh.Logger.Debugf("User %s already reviewed PR %d with state %s", r.user, pr.GetNumber(), review.GetState())
+ return utils.AddStatusNode(review.GetState() == "APPROVED", detail, details)
+ }
+ }
+ r.gh.Logger.Debugf("User %s has not reviewed PR %d yet", r.user, pr.GetNumber())
+
+ return utils.AddStatusNode(false, detail, details)
+}
+
+func ReviewByUser(gh *client.GitHub, user string) Requirement {
+ return &reviewByUser{gh, user}
+}
+
+// Reviewer Requirement
+type reviewByTeamMembers struct {
+ gh *client.GitHub
+ team string
+ count uint
+}
+
+var _ Requirement = &reviewByTeamMembers{}
+
+func (r *reviewByTeamMembers) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool {
+ detail := fmt.Sprintf("At least %d user(s) of the team %s approved pull request", r.count, r.team)
+
+ // If not a dry run, make the user a reviewer if he's not already
+ if !r.gh.DryRun {
+ requested := false
+ if reviewers := r.gh.ListPrReviewers(pr.GetNumber()); reviewers != nil {
+ for _, team := range reviewers.Teams {
+ if team.GetSlug() == r.team {
+ requested = true
+ break
+ }
+ }
+ }
+
+ if requested {
+ r.gh.Logger.Debugf("Review of team %s already requested on PR %d", r.team, pr.GetNumber())
+ } else {
+ r.gh.Logger.Debugf("Requesting review from team %s on PR %d", r.team, pr.GetNumber())
+ if _, _, err := r.gh.Client.PullRequests.RequestReviewers(
+ r.gh.Ctx,
+ r.gh.Owner,
+ r.gh.Repo,
+ pr.GetNumber(),
+ github.ReviewersRequest{
+ TeamReviewers: []string{r.team},
+ },
+ ); err != nil {
+ r.gh.Logger.Errorf("Unable to request review from team %s on PR %d : %v", r.team, pr.GetNumber(), err)
+ }
+ }
+ }
+
+ // Check how many members of this team already approved this PR
+ approved := uint(0)
+ members := r.gh.ListTeamMembers(r.team)
+ for _, review := range r.gh.ListPrReviews(pr.GetNumber()) {
+ for _, member := range members {
+ if review.GetUser().GetLogin() == member.GetLogin() {
+ if review.GetState() == "APPROVED" {
+ approved += 1
+ }
+ r.gh.Logger.Debugf("Member %s from team %s already reviewed PR %d with state %s (%d/%d required approval(s))", member.GetLogin(), r.team, pr.GetNumber(), review.GetState(), approved, r.count)
+ }
+ }
+ }
+
+ return utils.AddStatusNode(approved >= r.count, detail, details)
+}
+
+func ReviewByTeamMembers(gh *client.GitHub, team string, count uint) Requirement {
+ return &reviewByTeamMembers{gh, team, count}
+}
diff --git a/contribs/github_bot/utils/tree.go b/contribs/github_bot/utils/tree.go
new file mode 100644
index 000000000000..502f87e398df
--- /dev/null
+++ b/contribs/github_bot/utils/tree.go
@@ -0,0 +1,17 @@
+package utils
+
+import (
+ "fmt"
+
+ "github.com/xlab/treeprint"
+)
+
+func AddStatusNode(b bool, desc string, details treeprint.Tree) bool {
+ if b {
+ details.AddNode(fmt.Sprintf("🟢 %s", desc))
+ } else {
+ details.AddNode(fmt.Sprintf("🔴 %s", desc))
+ }
+
+ return b
+}