diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
new file mode 100644
index 00000000000..f51cecd9269
--- /dev/null
+++ b/.github/workflows/bot.yml
@@ -0,0 +1,104 @@
+name: GitHub Bot
+
+on:
+ # Watch for changes on PR state, assignees, labels and head branch
+ pull_request:
+ types:
+ - assigned
+ - unassigned
+ - labeled
+ - unlabeled
+ - opened
+ - reopened
+ - synchronize # PR head updated
+
+ # 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: Parse event inputs
+ id: pr-numbers
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # 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
+ 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 '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:
+ 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
+ env:
+ 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 00000000000..5a011573be4
--- /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 00000000000..4aa22b26926
--- /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 00000000000..cd66795df9a
--- /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 00000000000..b1e9debb261
--- /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 00000000000..be2b293e27e
--- /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 00000000000..db9d1fb45dd
--- /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 00000000000..bfb0dd78d3a
--- /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 00000000000..9dce8ea1a70
--- /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 00000000000..aa673875583
--- /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 00000000000..71be92e6edd
--- /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 00000000000..c346002d051
--- /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 00000000000..92e3b23dd12
--- /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 00000000000..32ddb2b2cb2
--- /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 00000000000..5e2d8a93984
--- /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 00000000000..c6d10429e62
--- /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 00000000000..53b50c6ed9a
--- /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 00000000000..cc12022011a
--- /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 00000000000..1a221e11bcd
--- /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 00000000000..ea6af698ca3
--- /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 00000000000..96a04ebce14
--- /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 00000000000..6854322521a
--- /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 00000000000..29c3f6d1404
--- /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 00000000000..1deff3b0531
--- /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 00000000000..c1a0bbd7518
--- /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 00000000000..6d89206ed92
--- /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 00000000000..ae48a1e9648
--- /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 00000000000..ce6e46becdb
--- /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 00000000000..502f87e398d
--- /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
+}
diff --git a/misc/github-bot/client/client.go b/misc/github-bot/client/client.go
new file mode 100644
index 00000000000..11e3f90a80b
--- /dev/null
+++ b/misc/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/misc/github-bot/comment.go b/misc/github-bot/comment.go
new file mode 100644
index 00000000000..ec884c1bb1c
--- /dev/null
+++ b/misc/github-bot/comment.go
@@ -0,0 +1,20 @@
+package main
+
+import "bot/client"
+
+type Auto struct {
+ Met bool
+ Description string
+}
+type Manual struct {
+ CheckedBy string
+ Description string
+}
+
+type Comment struct {
+ Auto []Auto
+ Manual []Manual
+}
+
+func onCommentUpdated(gh *client.Github) {
+}
diff --git a/misc/github-bot/comment.tmpl b/misc/github-bot/comment.tmpl
new file mode 100644
index 00000000000..1f7cd0ace58
--- /dev/null
+++ b/misc/github-bot/comment.tmpl
@@ -0,0 +1,21 @@
+# 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 .Auto }}
+
+ {{ if .Met }}🟢{{ else }}🟠{{ end }} {{ .Description }}
+
+ **TODO**
+
+{{ end }}
+
+## Manual Checks
+
+{{ range .Manual }}
+- [{{ if .CheckedBy }}x{{ else }} {{ end }}] {{ .Description }} {{ if .CheckedBy }}(checked by @{{ .CheckedBy }}){{ end }}{{ end }}
diff --git a/misc/github-bot/condition/assignee.go b/misc/github-bot/condition/assignee.go
new file mode 100644
index 00000000000..4190268e49d
--- /dev/null
+++ b/misc/github-bot/condition/assignee.go
@@ -0,0 +1,64 @@
+package condition
+
+import (
+ "bot/client"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Assignee Condition
+type assignee struct {
+ user string
+}
+
+var _ Condition = &assignee{}
+
+// GetText implements Condition
+func (a *assignee) GetText() string {
+ return fmt.Sprintf("A pull request assignee is user : %s", a.user)
+}
+
+// Validate implements Condition
+func (a *assignee) Validate(pr *github.PullRequest) bool {
+ for _, assignee := range pr.Assignees {
+ if a.user == assignee.GetLogin() {
+ return true
+ }
+ }
+ return false
+}
+
+func Assignee(user string) Condition {
+ return &assignee{user: user}
+}
+
+// AssigneeInTeam Condition
+type assigneeInTeam struct {
+ gh *client.Github
+ team string
+}
+
+var _ Condition = &assigneeInTeam{}
+
+// GetText implements Condition
+func (a *assigneeInTeam) GetText() string {
+ return fmt.Sprintf("A pull request assignee is a member of the team : %s", a.team)
+}
+
+// Validate implements Condition
+func (a *assigneeInTeam) Validate(pr *github.PullRequest) bool {
+ for _, member := range a.gh.ListTeamMembers(a.team) {
+ for _, assignee := range pr.Assignees {
+ if member.GetLogin() == assignee.GetLogin() {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func AssigneeInTeam(gh *client.Github, team string) Condition {
+ return &assigneeInTeam{gh: gh, team: team}
+}
diff --git a/misc/github-bot/condition/author.go b/misc/github-bot/condition/author.go
new file mode 100644
index 00000000000..a2821267b6b
--- /dev/null
+++ b/misc/github-bot/condition/author.go
@@ -0,0 +1,57 @@
+package condition
+
+import (
+ "bot/client"
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Author Condition
+type author struct {
+ user string
+}
+
+var _ Condition = &author{}
+
+// GetText implements Condition
+func (a *author) GetText() string {
+ return fmt.Sprintf("Pull request author is user : %v", a.user)
+}
+
+// Validate implements Condition
+func (a *author) Validate(pr *github.PullRequest) bool {
+ return a.user == pr.GetUser().GetLogin()
+}
+
+func Author(user string) Condition {
+ return &author{user: user}
+}
+
+// AuthorInTeam Condition
+type authorInTeam struct {
+ gh *client.Github
+ team string
+}
+
+var _ Condition = &authorInTeam{}
+
+// GetText implements Condition
+func (a *authorInTeam) GetText() string {
+ return fmt.Sprintf("Pull request author is a member of the team : %s", a.team)
+}
+
+// Validate implements Condition
+func (a *authorInTeam) Validate(pr *github.PullRequest) bool {
+ for _, member := range a.gh.ListTeamMembers(a.team) {
+ if member.GetLogin() == pr.GetUser().GetLogin() {
+ return true
+ }
+ }
+
+ return false
+}
+
+func AuthorInTeam(gh *client.Github, team string) Condition {
+ return &authorInTeam{gh: gh, team: team}
+}
diff --git a/misc/github-bot/condition/boolean.go b/misc/github-bot/condition/boolean.go
new file mode 100644
index 00000000000..0163da31a4f
--- /dev/null
+++ b/misc/github-bot/condition/boolean.go
@@ -0,0 +1,100 @@
+package condition
+
+import (
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// And Condition
+type and struct {
+ conditions []Condition
+}
+
+var _ Condition = &and{}
+
+// Validate implements Condition
+func (a *and) Validate(pr *github.PullRequest) bool {
+ for _, condition := range a.conditions {
+ if !condition.Validate(pr) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// GetText implements Condition
+func (a *and) GetText() string {
+ text := fmt.Sprintf("(%s", a.conditions[0].GetText())
+ for _, condition := range a.conditions[1:] {
+ text = fmt.Sprintf("%s AND %s", text, condition.GetText())
+ }
+
+ return text + ")"
+}
+
+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{}
+
+// Validate implements Condition
+func (o *or) Validate(pr *github.PullRequest) bool {
+ for _, condition := range o.conditions {
+ if condition.Validate(pr) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// GetText implements Condition
+func (o *or) GetText() string {
+ text := fmt.Sprintf("(%s", o.conditions[0].GetText())
+ for _, condition := range o.conditions[1:] {
+ text = fmt.Sprintf("%s OR %s", text, condition.GetText())
+ }
+
+ return text + ")"
+}
+
+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 = ¬{}
+
+// Validate implements Condition
+func (n *not) Validate(pr *github.PullRequest) bool {
+ return !n.cond.Validate(pr)
+}
+
+// GetText implements Condition
+func (n *not) GetText() string {
+ return fmt.Sprintf("NOT %s", n.cond.GetText())
+}
+
+func Not(cond Condition) Condition {
+ return ¬{cond}
+}
diff --git a/misc/github-bot/condition/branch.go b/misc/github-bot/condition/branch.go
new file mode 100644
index 00000000000..db8003adf2b
--- /dev/null
+++ b/misc/github-bot/condition/branch.go
@@ -0,0 +1,50 @@
+package condition
+
+import (
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// BaseBranch Condition
+type baseBranch struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &baseBranch{}
+
+// Validate implements Condition
+func (b *baseBranch) Validate(pr *github.PullRequest) bool {
+ return b.pattern.MatchString(pr.GetBase().GetRef())
+}
+
+// GetText implements Condition
+func (b *baseBranch) GetText() string {
+ return fmt.Sprintf("The base branch match this pattern : %s", b.pattern.String())
+}
+
+func BaseBranch(pattern string) Condition {
+ return &baseBranch{pattern: regexp.MustCompile(pattern)}
+}
+
+// HeadBranch Condition
+type headBranch struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &headBranch{}
+
+// Validate implements Condition
+func (h *headBranch) Validate(pr *github.PullRequest) bool {
+ return h.pattern.MatchString(pr.GetHead().GetRef())
+}
+
+// GetText implements Condition
+func (h *headBranch) GetText() string {
+ return fmt.Sprintf("The head branch match this pattern : %s", h.pattern.String())
+}
+
+func HeadBranch(pattern string) Condition {
+ return &headBranch{pattern: regexp.MustCompile(pattern)}
+}
diff --git a/misc/github-bot/condition/condition.go b/misc/github-bot/condition/condition.go
new file mode 100644
index 00000000000..a3226647e79
--- /dev/null
+++ b/misc/github-bot/condition/condition.go
@@ -0,0 +1,13 @@
+package condition
+
+import (
+ "github.com/google/go-github/v66/github"
+)
+
+type Condition interface {
+ // Check if the Condition is met by this PR
+ Validate(pr *github.PullRequest) bool
+
+ // Get a text representation of this Condition
+ GetText() string
+}
diff --git a/misc/github-bot/condition/constant.go b/misc/github-bot/condition/constant.go
new file mode 100644
index 00000000000..ed01a01d9d1
--- /dev/null
+++ b/misc/github-bot/condition/constant.go
@@ -0,0 +1,43 @@
+package condition
+
+import (
+ "github.com/google/go-github/v66/github"
+)
+
+// Always Condition
+type always struct{}
+
+var _ Condition = &always{}
+
+// Validate implements Condition
+func (*always) Validate(_ *github.PullRequest) bool {
+ return true
+}
+
+// GetText implements Condition
+func (*always) GetText() string {
+ return "On every pull request"
+}
+
+func Always() Condition {
+ return &always{}
+}
+
+// Never Condition
+type never struct{}
+
+var _ Condition = &never{}
+
+// Validate implements Condition
+func (*never) Validate(_ *github.PullRequest) bool {
+ return false
+}
+
+// GetText implements Condition
+func (*never) GetText() string {
+ return "On no pull request"
+}
+
+func Never() Condition {
+ return &never{}
+}
diff --git a/misc/github-bot/condition/file.go b/misc/github-bot/condition/file.go
new file mode 100644
index 00000000000..a6f49800f65
--- /dev/null
+++ b/misc/github-bot/condition/file.go
@@ -0,0 +1,61 @@
+package condition
+
+import (
+ "bot/client"
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// FileChanged Condition
+type fileChanged struct {
+ gh *client.Github
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &fileChanged{}
+
+// Validate implements Condition
+func (fc *fileChanged) Validate(pr *github.PullRequest) bool {
+ 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()) {
+ fc.gh.Logger.Debugf("File %s is matching pattern %s", file.GetFilename(), fc.pattern.String())
+ return true
+ }
+ }
+
+ if response.NextPage == 0 {
+ break
+ }
+ opts.Page = response.NextPage
+ }
+
+ return false
+}
+
+// GetText implements Condition
+func (fc *fileChanged) GetText() string {
+ return fmt.Sprintf("A changed file match this pattern : %s", fc.pattern.String())
+}
+
+func FileChanged(gh *client.Github, pattern string) Condition {
+ return &fileChanged{gh: gh, pattern: regexp.MustCompile(pattern)}
+}
diff --git a/misc/github-bot/condition/label.go b/misc/github-bot/condition/label.go
new file mode 100644
index 00000000000..3c6b929afe5
--- /dev/null
+++ b/misc/github-bot/condition/label.go
@@ -0,0 +1,34 @@
+package condition
+
+import (
+ "fmt"
+ "regexp"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Label Condition
+type label struct {
+ pattern *regexp.Regexp
+}
+
+var _ Condition = &label{}
+
+// Validate implements Condition
+func (l *label) Validate(pr *github.PullRequest) bool {
+ for _, label := range pr.Labels {
+ if l.pattern.MatchString(label.GetName()) {
+ return true
+ }
+ }
+ return false
+}
+
+// GetText implements Condition
+func (l *label) GetText() string {
+ return fmt.Sprintf("A label match this pattern : %s", l.pattern.String())
+}
+
+func Label(pattern string) Condition {
+ return &label{pattern: regexp.MustCompile(pattern)}
+}
diff --git a/misc/github-bot/config.go b/misc/github-bot/config.go
new file mode 100644
index 00000000000..d5c586fa52e
--- /dev/null
+++ b/misc/github-bot/config.go
@@ -0,0 +1,59 @@
+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
+ // TODO: remomve that
+ CheckedBy string
+}
+
+func config(gh *client.Github) ([]automaticCheck, []manualCheck) {
+ return []automaticCheck{
+ {
+ Description: "Changes on 'tm2' folder should be reviewed/authored 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.Always(),
+ Then: r.MaintainerCanModify(),
+ },
+ }, []manualCheck{
+ {
+ Description: "Manual check #1",
+ CheckedBy: "",
+ },
+ {
+ Description: "Manual check #2",
+ CheckedBy: "aeddi",
+ },
+ {
+ Description: "Manual check #3",
+ CheckedBy: "moul",
+ },
+ }
+}
diff --git a/misc/github-bot/go.mod b/misc/github-bot/go.mod
new file mode 100644
index 00000000000..618fa16bdeb
--- /dev/null
+++ b/misc/github-bot/go.mod
@@ -0,0 +1,10 @@
+module bot
+
+go 1.22.2
+
+require github.com/google/go-github/v66 v66.0.0
+
+require (
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/sethvargo/go-githubactions v1.3.0 // indirect
+)
diff --git a/misc/github-bot/go.sum b/misc/github-bot/go.sum
new file mode 100644
index 00000000000..ee3974d68e8
--- /dev/null
+++ b/misc/github-bot/go.sum
@@ -0,0 +1,10 @@
+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/sethvargo/go-githubactions v1.3.0 h1:Kg633LIUV2IrJsqy2MfveiED/Ouo+H2P0itWS0eLh8A=
+github.com/sethvargo/go-githubactions v1.3.0/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/misc/github-bot/logger/action.go b/misc/github-bot/logger/action.go
new file mode 100644
index 00000000000..c6d10429e62
--- /dev/null
+++ b/misc/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/misc/github-bot/logger/logger.go b/misc/github-bot/logger/logger.go
new file mode 100644
index 00000000000..53b50c6ed9a
--- /dev/null
+++ b/misc/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/misc/github-bot/logger/terminal.go b/misc/github-bot/logger/terminal.go
new file mode 100644
index 00000000000..aeb3835e170
--- /dev/null
+++ b/misc/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/misc/github-bot/main.go b/misc/github-bot/main.go
new file mode 100644
index 00000000000..e7548ebec8c
--- /dev/null
+++ b/misc/github-bot/main.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "bot/client"
+ "bot/param"
+ "bytes"
+ "text/template"
+
+ "github.com/google/go-github/v66/github"
+)
+
+func main() {
+ // Get params by parsing CLI flags and/or Github Actions context
+ params := param.Get()
+
+ // Init Github API client
+ gh := client.New(params)
+
+ // TODO:cleanup
+ onCommentUpdated(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
+ }
+ }
+
+ tmplFile := "comment.tmpl"
+ tmpl, err := template.New(tmplFile).ParseFiles(tmplFile)
+ if err != nil {
+ panic(err)
+ }
+
+ auto, manual := config(gh)
+ // Process all pull requests
+ for _, pr := range prs {
+ com := Comment{}
+ for _, rule := range auto {
+ if rule.If.Validate(pr) {
+ gh.Logger.Infof(rule.If.GetText())
+
+ c := Auto{Description: rule.Description, Met: false}
+
+ if !rule.Then.Validate(pr) {
+ gh.Logger.Infof(rule.Then.GetText())
+ c.Met = true
+ }
+
+ com.Auto = append(com.Auto, c)
+ }
+ }
+
+ for _, rule := range manual {
+ com.Manual = append(com.Manual, Manual{
+ Description: rule.Description,
+ CheckedBy: rule.CheckedBy,
+ })
+ }
+
+ var commentBytes bytes.Buffer
+ err = tmpl.Execute(&commentBytes, com)
+ if err != nil {
+ panic(err)
+ }
+
+ comment := gh.SetBotComment(commentBytes.String(), pr.GetNumber())
+
+ context := "Merge Requirements"
+ state := "pending"
+ targetURL := comment.GetHTMLURL()
+ description := "Some requirements are not met yet. See bot comment."
+
+ 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/misc/github-bot/param/param.go b/misc/github-bot/param/param.go
new file mode 100644
index 00000000000..54717511a21
--- /dev/null
+++ b/misc/github-bot/param/param.go
@@ -0,0 +1,92 @@
+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 met (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 check, if empty, will be retrieved from Github Actions context")
+ flag.StringVar(&p.Repo, "repo", "", "repo to check, if empty, will be retrieved from Github Actions context")
+ flag.BoolVar(&p.PrAll, "pr-all", false, "validate all pull requests opened on the repo")
+ flag.TextVar(&p.PrNums, "pr-numbers", PrList(nil), "pull request(s) to validate, must be a comma seperated list of PR numbers, e.g '42,1337,2345'. If empty, PR to check 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 must specify at most one of '-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"
+ issue, ok := actionCtx.Event["issue"].(map[string]any)
+ if !ok {
+ errorUsage(errMsg)
+ }
+ num, ok := issue["number"].(float64)
+ if !ok || num <= 0 {
+ errorUsage(errMsg)
+ }
+ p.PrNums = PrList([]int{int(num)})
+ }
+ }
+
+ return p
+}
diff --git a/misc/github-bot/param/prlist.go b/misc/github-bot/param/prlist.go
new file mode 100644
index 00000000000..96a04ebce14
--- /dev/null
+++ b/misc/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/misc/github-bot/requirement/assignee.go b/misc/github-bot/requirement/assignee.go
new file mode 100644
index 00000000000..0b308e95c41
--- /dev/null
+++ b/misc/github-bot/requirement/assignee.go
@@ -0,0 +1,47 @@
+package requirement
+
+import (
+ "bot/client"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Assignee Requirement
+type assignee struct {
+ gh *client.Github
+ user string
+}
+
+var _ Requirement = &assignee{}
+
+// GetText implements Requirement
+func (a *assignee) GetText() string {
+ return "TODO"
+}
+
+// Validate implements Requirement
+func (a *assignee) Validate(pr *github.PullRequest) bool {
+ // Check if user was already assigned to PR
+ for _, assignee := range pr.Assignees {
+ if a.user == assignee.GetLogin() {
+ return true
+ }
+ }
+
+ // If not, 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 false
+ }
+ return true
+}
+
+func Assignee(gh *client.Github, user string) Requirement {
+ return &assignee{gh: gh, user: user}
+}
diff --git a/misc/github-bot/requirement/author.go b/misc/github-bot/requirement/author.go
new file mode 100644
index 00000000000..0d5808436f5
--- /dev/null
+++ b/misc/github-bot/requirement/author.go
@@ -0,0 +1,14 @@
+package requirement
+
+import (
+ "bot/client"
+ "bot/condition"
+)
+
+func Author(user string) Requirement {
+ return condition.Author(user)
+}
+
+func AuthorInTeam(gh *client.Github, team string) Requirement {
+ return condition.AuthorInTeam(gh, team)
+}
diff --git a/misc/github-bot/requirement/boolean.go b/misc/github-bot/requirement/boolean.go
new file mode 100644
index 00000000000..ddb2af3c91a
--- /dev/null
+++ b/misc/github-bot/requirement/boolean.go
@@ -0,0 +1,100 @@
+package requirement
+
+import (
+ "fmt"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// And Requirement
+type and struct {
+ requirements []Requirement
+}
+
+var _ Requirement = &and{}
+
+// Validate implements Requirement
+func (a *and) Validate(pr *github.PullRequest) bool {
+ for _, requirement := range a.requirements {
+ if !requirement.Validate(pr) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// GetText implements Requirement
+func (a *and) GetText() string {
+ text := fmt.Sprintf("(%s", a.requirements[0].GetText())
+ for _, requirement := range a.requirements[1:] {
+ text = fmt.Sprintf("%s AND %s", text, requirement.GetText())
+ }
+
+ return text + ")"
+}
+
+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{}
+
+// Validate implements Requirement
+func (o *or) Validate(pr *github.PullRequest) bool {
+ for _, requirement := range o.requirements {
+ if !requirement.Validate(pr) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// GetText implements Requirement
+func (o *or) GetText() string {
+ text := fmt.Sprintf("(%s", o.requirements[0].GetText())
+ for _, requirement := range o.requirements[1:] {
+ text = fmt.Sprintf("%s OR %s", text, requirement.GetText())
+ }
+
+ return text + ")"
+}
+
+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 = ¬{}
+
+// Validate implements Requirement
+func (n *not) Validate(pr *github.PullRequest) bool {
+ return !n.req.Validate(pr)
+}
+
+// GetText implements Requirement
+func (n *not) GetText() string {
+ return fmt.Sprintf("NOT %s", n.req.GetText())
+}
+
+func Not(req Requirement) Requirement {
+ return ¬{req}
+}
diff --git a/misc/github-bot/requirement/checkbox.go b/misc/github-bot/requirement/checkbox.go
new file mode 100644
index 00000000000..fe60467a9e2
--- /dev/null
+++ b/misc/github-bot/requirement/checkbox.go
@@ -0,0 +1,29 @@
+package requirement
+
+import (
+ "bot/client"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Checkbox Requirement
+type checkbox struct {
+ gh *client.Github
+ desc string
+}
+
+var _ Requirement = &checkbox{}
+
+// GetText implements Requirement
+func (c *checkbox) GetText() string {
+ return ""
+}
+
+// Validate implements Requirement
+func (c *checkbox) Validate(pr *github.PullRequest) bool {
+ return false
+}
+
+func Checkbox(gh *client.Github, desc string) Requirement {
+ return &checkbox{gh: gh, desc: desc}
+}
diff --git a/misc/github-bot/requirement/label.go b/misc/github-bot/requirement/label.go
new file mode 100644
index 00000000000..f9523ce5238
--- /dev/null
+++ b/misc/github-bot/requirement/label.go
@@ -0,0 +1,47 @@
+package requirement
+
+import (
+ "bot/client"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Label Requirement
+type label struct {
+ gh *client.Github
+ name string
+}
+
+var _ Requirement = &label{}
+
+// Validate implements Requirement
+func (l *label) Validate(pr *github.PullRequest) bool {
+ // Check if label was already added to PR
+ for _, label := range pr.Labels {
+ if l.name == label.GetName() {
+ return true
+ }
+ }
+
+ // If not, add 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 false
+ }
+ return true
+}
+
+// GetText implements Requirement
+func (l *label) GetText() string {
+ return "TODO"
+}
+
+func Label(gh *client.Github, name string) Requirement {
+ return &label{gh, name}
+}
diff --git a/misc/github-bot/requirement/maintainer.go b/misc/github-bot/requirement/maintainer.go
new file mode 100644
index 00000000000..96ba5910cbe
--- /dev/null
+++ b/misc/github-bot/requirement/maintainer.go
@@ -0,0 +1,24 @@
+package requirement
+
+import (
+ "github.com/google/go-github/v66/github"
+)
+
+// MaintainerCanModify Requirement
+type maintainerCanModify struct{}
+
+var _ Requirement = &maintainerCanModify{}
+
+// GetText implements Requirement
+func (a *maintainerCanModify) GetText() string {
+ return "TODO"
+}
+
+// Validate implements Requirement
+func (a *maintainerCanModify) Validate(pr *github.PullRequest) bool {
+ return pr.GetMaintainerCanModify()
+}
+
+func MaintainerCanModify() Requirement {
+ return &maintainerCanModify{}
+}
diff --git a/misc/github-bot/requirement/requirement.go b/misc/github-bot/requirement/requirement.go
new file mode 100644
index 00000000000..982cf7ee14a
--- /dev/null
+++ b/misc/github-bot/requirement/requirement.go
@@ -0,0 +1,13 @@
+package requirement
+
+import (
+ "github.com/google/go-github/v66/github"
+)
+
+type Requirement interface {
+ // Check if the Requirement is met by this PR
+ Validate(pr *github.PullRequest) bool
+
+ // Get a text representation of this Requirement
+ GetText() string
+}
diff --git a/misc/github-bot/requirement/reviewer.go b/misc/github-bot/requirement/reviewer.go
new file mode 100644
index 00000000000..8882be00634
--- /dev/null
+++ b/misc/github-bot/requirement/reviewer.go
@@ -0,0 +1,139 @@
+package requirement
+
+import (
+ "bot/client"
+
+ "github.com/google/go-github/v66/github"
+)
+
+// Reviewer Requirement
+type reviewByUser struct {
+ gh *client.Github
+ user string
+}
+
+var _ Requirement = &reviewByUser{}
+
+// GetText implements Requirement
+func (r *reviewByUser) GetText() string {
+ return "TODO"
+}
+
+// Validate implements Requirement
+func (r *reviewByUser) Validate(pr *github.PullRequest) bool {
+ // 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 review.GetState() == "APPROVED"
+ }
+ }
+ r.gh.Logger.Debugf("User %s has not approved PR %d yet", r.user, pr.GetNumber())
+
+ return false
+}
+
+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{}
+
+// GetText implements Requirement
+func (r *reviewByTeamMembers) GetText() string {
+ return "TODO"
+}
+
+// Validate implements Requirement
+func (r *reviewByTeamMembers) Validate(pr *github.PullRequest) bool {
+ // 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
+ var 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)
+ if approved >= r.count {
+ return true
+ }
+ }
+ }
+ }
+ r.gh.Logger.Debugf("Not enough members from team %s have approved PR %d (%d/%d required approval(s))", r.team, pr.GetNumber(), approved, r.count)
+
+ return false
+}
+
+func ReviewByTeamMembers(gh *client.Github, team string, count uint) Requirement {
+ return &reviewByTeamMembers{gh, team, count}
+}