From 7aa06b86fe3ef05ad5b2d1c779448bbcfec1f6e4 Mon Sep 17 00:00:00 2001 From: aeddi Date: Mon, 28 Oct 2024 10:58:21 +0100 Subject: [PATCH] feat: add github bot --- .github/workflows/bot.yml | 104 +++++++ contribs/github_bot/client/client.go | 247 +++++++++++++++ contribs/github_bot/comment.go | 283 ++++++++++++++++++ contribs/github_bot/comment.tmpl | 51 ++++ contribs/github_bot/condition/assignee.go | 59 ++++ contribs/github_bot/condition/author.go | 53 ++++ contribs/github_bot/condition/boolean.go | 100 +++++++ contribs/github_bot/condition/branch.go | 48 +++ contribs/github_bot/condition/condition.go | 12 + contribs/github_bot/condition/constant.go | 34 +++ contribs/github_bot/condition/file.go | 57 ++++ contribs/github_bot/condition/label.go | 33 ++ contribs/github_bot/config.go | 97 ++++++ contribs/github_bot/go.mod | 11 + contribs/github_bot/go.sum | 22 ++ contribs/github_bot/logger/action.go | 43 +++ contribs/github_bot/logger/logger.go | 34 +++ contribs/github_bot/logger/terminal.go | 55 ++++ contribs/github_bot/main.go | 117 ++++++++ contribs/github_bot/param/param.go | 109 +++++++ contribs/github_bot/param/prlist.go | 45 +++ contribs/github_bot/requirement/assignee.go | 52 ++++ contribs/github_bot/requirement/author.go | 53 ++++ contribs/github_bot/requirement/boolean.go | 100 +++++++ contribs/github_bot/requirement/branch.go | 52 ++++ contribs/github_bot/requirement/label.go | 52 ++++ contribs/github_bot/requirement/maintainer.go | 25 ++ .../github_bot/requirement/requirement.go | 12 + contribs/github_bot/requirement/reviewer.go | 130 ++++++++ contribs/github_bot/utils/tree.go | 17 ++ misc/github-bot/client/client.go | 235 +++++++++++++++ misc/github-bot/comment.go | 20 ++ misc/github-bot/comment.tmpl | 21 ++ misc/github-bot/condition/assignee.go | 64 ++++ misc/github-bot/condition/author.go | 57 ++++ misc/github-bot/condition/boolean.go | 100 +++++++ misc/github-bot/condition/branch.go | 50 ++++ misc/github-bot/condition/condition.go | 13 + misc/github-bot/condition/constant.go | 43 +++ misc/github-bot/condition/file.go | 61 ++++ misc/github-bot/condition/label.go | 34 +++ misc/github-bot/config.go | 59 ++++ misc/github-bot/go.mod | 10 + misc/github-bot/go.sum | 10 + misc/github-bot/logger/action.go | 43 +++ misc/github-bot/logger/logger.go | 34 +++ misc/github-bot/logger/terminal.go | 55 ++++ misc/github-bot/main.go | 112 +++++++ misc/github-bot/param/param.go | 92 ++++++ misc/github-bot/param/prlist.go | 45 +++ misc/github-bot/requirement/assignee.go | 47 +++ misc/github-bot/requirement/author.go | 14 + misc/github-bot/requirement/boolean.go | 100 +++++++ misc/github-bot/requirement/checkbox.go | 29 ++ misc/github-bot/requirement/label.go | 47 +++ misc/github-bot/requirement/maintainer.go | 24 ++ misc/github-bot/requirement/requirement.go | 13 + misc/github-bot/requirement/reviewer.go | 139 +++++++++ 58 files changed, 3678 insertions(+) create mode 100644 .github/workflows/bot.yml create mode 100644 contribs/github_bot/client/client.go create mode 100644 contribs/github_bot/comment.go create mode 100644 contribs/github_bot/comment.tmpl create mode 100644 contribs/github_bot/condition/assignee.go create mode 100644 contribs/github_bot/condition/author.go create mode 100644 contribs/github_bot/condition/boolean.go create mode 100644 contribs/github_bot/condition/branch.go create mode 100644 contribs/github_bot/condition/condition.go create mode 100644 contribs/github_bot/condition/constant.go create mode 100644 contribs/github_bot/condition/file.go create mode 100644 contribs/github_bot/condition/label.go create mode 100644 contribs/github_bot/config.go create mode 100644 contribs/github_bot/go.mod create mode 100644 contribs/github_bot/go.sum create mode 100644 contribs/github_bot/logger/action.go create mode 100644 contribs/github_bot/logger/logger.go create mode 100644 contribs/github_bot/logger/terminal.go create mode 100644 contribs/github_bot/main.go create mode 100644 contribs/github_bot/param/param.go create mode 100644 contribs/github_bot/param/prlist.go create mode 100644 contribs/github_bot/requirement/assignee.go create mode 100644 contribs/github_bot/requirement/author.go create mode 100644 contribs/github_bot/requirement/boolean.go create mode 100644 contribs/github_bot/requirement/branch.go create mode 100644 contribs/github_bot/requirement/label.go create mode 100644 contribs/github_bot/requirement/maintainer.go create mode 100644 contribs/github_bot/requirement/requirement.go create mode 100644 contribs/github_bot/requirement/reviewer.go create mode 100644 contribs/github_bot/utils/tree.go create mode 100644 misc/github-bot/client/client.go create mode 100644 misc/github-bot/comment.go create mode 100644 misc/github-bot/comment.tmpl create mode 100644 misc/github-bot/condition/assignee.go create mode 100644 misc/github-bot/condition/author.go create mode 100644 misc/github-bot/condition/boolean.go create mode 100644 misc/github-bot/condition/branch.go create mode 100644 misc/github-bot/condition/condition.go create mode 100644 misc/github-bot/condition/constant.go create mode 100644 misc/github-bot/condition/file.go create mode 100644 misc/github-bot/condition/label.go create mode 100644 misc/github-bot/config.go create mode 100644 misc/github-bot/go.mod create mode 100644 misc/github-bot/go.sum create mode 100644 misc/github-bot/logger/action.go create mode 100644 misc/github-bot/logger/logger.go create mode 100644 misc/github-bot/logger/terminal.go create mode 100644 misc/github-bot/main.go create mode 100644 misc/github-bot/param/param.go create mode 100644 misc/github-bot/param/prlist.go create mode 100644 misc/github-bot/requirement/assignee.go create mode 100644 misc/github-bot/requirement/author.go create mode 100644 misc/github-bot/requirement/boolean.go create mode 100644 misc/github-bot/requirement/checkbox.go create mode 100644 misc/github-bot/requirement/label.go create mode 100644 misc/github-bot/requirement/maintainer.go create mode 100644 misc/github-bot/requirement/requirement.go create mode 100644 misc/github-bot/requirement/reviewer.go 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..51ff3bdba72 --- /dev/null +++ b/contribs/github_bot/client/client.go @@ -0,0 +1,247 @@ +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) IsUserInTeams(user string, teams []string) bool { + for _, team := range teams { + for _, member := range gh.ListTeamMembers(team) { + if member.GetLogin() == user { + return true + } + } + } + + return false +} + +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..e61b82637a2 --- /dev/null +++ b/contribs/github_bot/comment.go @@ -0,0 +1,283 @@ +package main + +import ( + "bot/client" + "bytes" + "fmt" + "os" + "regexp" + "strings" + "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 checkbox status, +// the check description and the username who checked it +func getCommentManualChecks(commentBody string) map[string][2]string { + checks := make(map[string][2]string) + + reg := regexp.MustCompile(`(?m:^- \[([ x])\] (.+)?$)`) + subReg := regexp.MustCompile(`(?m:(.+) \(checked by @(\w+)\)$)`) + matches := reg.FindAllStringSubmatch(commentBody, -1) + + for _, match := range matches { + if subMatches := subReg.FindAllStringSubmatch(match[2], -1); len(subMatches) > 0 { + checks[subMatches[0][1]] = [2]string{match[1], subMatches[0][2]} + } else { + checks[match[2]] = [2]string{match[1]} + } + } + + return checks +} + +// handleCommentUpdate 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 it) +// - the actor / comment editor has permission to modify this checkbox (or restore it) +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(current) + previousChecks := getCommentManualChecks(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 { + if gh.IsUserInTeams(actionCtx.Actor, teams) { + 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 strings.TrimSpace(currentChecks[key][0]) == "x" { + replacement := fmt.Sprintf("- [%s] %s (checked by @%s)", currentChecks[key][0], key, actionCtx.Actor) + edited = reg.ReplaceAllString(current, replacement) + } else { + replacement := fmt.Sprintf("- [%s] %s", currentChecks[key][0], key) + edited = reg.ReplaceAllString(current, replacement) + } + } + } + + // Update comment with username + if edited != "" { + gh.SetBotComment(edited, int(num)) + gh.Logger.Debugf("Comment manual checks updated successfuly") + } +} + +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 { + gh.Logger.Infof("AUTO NOT Satisfied", auto.Description) + allSatisfied = false + break + } + } + + if allSatisfied { + for _, manual := range content.ManualRules { + if manual.CheckedBy == "" { + gh.Logger.Infof("AUTO NOT Satisfied", manual.Description) + allSatisfied = false + break + } + } + } + + if allSatisfied { + state = "success" + description = "All requirements are satisfied." + } + + gh.Logger.Infof("STATUS", state, description) + + 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..a1f94dab75d --- /dev/null +++ b/contribs/github_bot/comment.tmpl @@ -0,0 +1,51 @@ +# 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 [configuration file](https://github.com/gnolang/gno/tree/master/contribs/github-bot/config.go). + +## Automated Checks + +{{ range .AutoRules }} {{ if .Satisfied }}🟢{{ else }}🟠{{ end }} {{ .Description }} +{{ end }} + +{{ if .AutoRules }}
Details
+{{ range .AutoRules }} +
{{ .Description }}
+ +### If : +``` +{{ .ConditionDetails }} +``` +### Then : +``` +{{ .RequirementDetails }} +``` +
+{{ end }} +
+{{ else }}*No automated checks match this pull request.*{{ end }} + +## Manual Checks + +{{ range .ManualRules }}- [{{ if .CheckedBy }}x{{ else }} {{ end }}] {{ .Description }}{{ if .CheckedBy }} (checked by @{{ .CheckedBy }}){{ end }} +{{ end }} + +{{ if .ManualRules }}
Details
+{{ range .ManualRules }} +
{{ .Description }}
+ +### If : +``` +{{ .ConditionDetails }} +``` +### Can be checked by : +{{range $item := .Teams }} - team {{ $item }} +{{ else }} +- Any user with comment edit permission +{{end}} +
+{{ end }} +
+{{ else }}*No manual checks match this pull request.*{{ 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..51e11345583 --- /dev/null +++ b/contribs/github_bot/config.go @@ -0,0 +1,97 @@ +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) { + auto := []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.Always(), + Then: r.MaintainerCanModify(), + }, + { + Description: "Pull request head branch must be up to date with its base", + If: c.Always(), // Or only if c.BaseBranch("main") ? + Then: r.UpToDateWith(gh, r.PR_BASE), + }, + } + + manual := []manualCheck{ + { + Description: "Determine if infra needs to be updated", + If: c.And( + c.BaseBranch("main"), + c.Or( + c.FileChanged(gh, "misc/deployments"), + c.FileChanged(gh, `misc/docker-\.*`), + c.FileChanged(gh, "tm2/pkg/p2p"), + ), + ), + Teams: []string{"tech-staff"}, + }, + { + Description: "The code style is satisfactory", + If: c.And( + c.BaseBranch("main"), + c.Or( + c.FileChanged(gh, `.*\.go`), + c.FileChanged(gh, `.*\.js`), + ), + ), + Teams: []string{"tech-staff"}, + }, + { + Description: "The documentation is accurate and relevant", + If: c.FileChanged(gh, `.*\.md`), + Teams: []string{ + "tech-staff", + "devrels", + }, + }, + } + + // Check for duplicates in manual rule descriptions + // (need to be unique for the bot operations) + unique := make(map[string]struct{}) + for _, rule := range manual { + if _, exists := unique[rule.Description]; exists { + gh.Logger.Fatalf("Manual rule description must be unique (duplicate : %s)", rule.Description) + } + unique[rule.Description] = struct{}{} + } + + return auto, manual +} 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..6e524deb1f0 --- /dev/null +++ b/contribs/github_bot/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "bot/client" + "bot/param" + "sync" + + "github.com/google/go-github/v66/github" + "github.com/xlab/treeprint" +) + +func main() { + // Retrieve params by parsing CLI flags and/or GitHub Actions context + params := param.Get() + + // Init GitHub API client + gh := client.New(params) + + // Handle comment update, if any + handleCommentUpdate(gh) + + // Retrieve a slice of pull requests to process + var ( + prs []*github.PullRequest + err error + ) + + // If requested, retrieve 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 retrieve all opened pull requests : %v", err) + } + + // Otherwise, retrieve 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 retrieve specified pull request (%d) : %v", prNum, err) + } + prs[i] = pr + } + } + + // Process all pull requests in parrallel + autoRules, manualRules := config(gh) + var wg sync.WaitGroup + wg.Add(len(prs)) + + for _, pr := range prs { + go func(pr *github.PullRequest) { + defer wg.Done() + commentContent := CommentContent{} + + // Iterate over all automatic rules in config + for _, autoRule := range autoRules { + ifDetails := treeprint.NewWithRoot("🟢 Condition met") + + // Check if conditions 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 requirements 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") + + // Retrieve manual check states + checks := make(map[string][2]string) + if comment := gh.GetBotComment(pr.GetNumber()); comment != nil { + checks = getCommentManualChecks(comment.GetBody()) + } + + // Check if conditions 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) + } + }(pr) + } + wg.Wait() +} 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/branch.go b/contribs/github_bot/requirement/branch.go new file mode 100644 index 00000000000..9b1eafde05d --- /dev/null +++ b/contribs/github_bot/requirement/branch.go @@ -0,0 +1,52 @@ +package requirement + +import ( + "bot/client" + "bot/utils" + "fmt" + + "github.com/google/go-github/v66/github" + "github.com/xlab/treeprint" +) + +// Pass this to UpToDateWith constructor to check the PR head branch +// against its base branch +const PR_BASE = "PR_BASE" + +// UpToDateWith Requirement +type upToDateWith struct { + gh *client.GitHub + base string +} + +var _ Requirement = &author{} + +func (u *upToDateWith) IsSatisfied(pr *github.PullRequest, details treeprint.Tree) bool { + base := u.base + if u.base == PR_BASE { + base = pr.GetBase().GetRef() + } + head := pr.GetHead().GetRef() + + cmp, _, err := u.gh.Client.Repositories.CompareCommits(u.gh.Ctx, u.gh.Owner, u.gh.Repo, base, head, nil) + if err != nil { + u.gh.Logger.Errorf("Unable to compare head branch (%s) and base (%s) : %v", head, base, err) + return false + } + + return utils.AddStatusNode( + cmp.GetBehindBy() == 0, + fmt.Sprintf( + "Head branch (%s) is up to date with (%s) : behind by %d / ahead by %d", + head, + base, + cmp.GetBehindBy(), + cmp.GetAheadBy(), + ), + details, + ) +} + +func UpToDateWith(gh *client.GitHub, base string) Requirement { + return &upToDateWith{gh, base} +} 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..b72011215f8 --- /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 + github.com/sethvargo/go-githubactions v1.3.0 +) + +require github.com/google/go-querystring v1.1.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} +}