Skip to content

Commit

Permalink
feat: add github bot
Browse files Browse the repository at this point in the history
  • Loading branch information
aeddi committed Nov 6, 2024
1 parent 534e652 commit 7aa06b8
Show file tree
Hide file tree
Showing 58 changed files with 3,678 additions and 0 deletions.
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
247 changes: 247 additions & 0 deletions contribs/github_bot/client/client.go
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 234 in contribs/github_bot/client/client.go

View workflow job for this annotation

GitHub Actions / Run Main (github_bot) / Go Linter / lint

lostcancel: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak (govet)
} 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

0 comments on commit 7aa06b8

Please sign in to comment.