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