Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ci: add a github bot to support advanced PR review workflows #3037

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/bot.yml
Original file line number Diff line number Diff line change
@@ -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
235 changes: 235 additions & 0 deletions contribs/github_bot/client/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading