Skip to content

Commit

Permalink
Initial version of a ToBeReviewed pester-bot.
Browse files Browse the repository at this point in the history
This bot is expected to be run periodically as a GitHub Action
using the identity of a GitHub App. It will check recently submitted
Pull Requests for any which went in ToBeReviewed (i.e. without any
Approval on the PR). It will file an issue to followup.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
  • Loading branch information
DentonGentry committed Feb 20, 2022
0 parents commit 70b3a8f
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 0 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/tbr-bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: To-Be-Reviewed Bot
permissions: read-all

on:
schedule:
- cron: '*/30 * * * *'

jobs:
bot:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2

- name: build-a-bot
run: go build ./...

- name: run-a-bot
run: ./tbr-audit --org="tailscale" --bugrepo="ToBeReviewedBot" --repos="corp,tailscale"
env:
GH_APP_ID: ${{ secrets.GH_APP_ID }}
GH_APP_INSTALL_ID: ${{ secrets.GH_APP_INSTALL_ID }}
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
./tbr-audit
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
This bot is expected to be run periodically as a GitHub Action
using the identity of a GitHub App. It will check recently submitted
Pull Requests for any which went in ToBeReviewed (i.e. without
any Approval on the PR).

It will file an issue to followup on the review.
192 changes: 192 additions & 0 deletions cmd/tbr-audit/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"

"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v42/github"
)

var org string = "example-github-organization"

// Return an HTTP client suitable to use with the GitHub API, initialized with
// our API keys and certificate.
//
// This bot expects to run as an organization-level GitHub app, as seen in
// https://github.com/organizations/<name>/settings/installations
// This gives it permission to access private repos without using an individual's
// Personal Access Token.
func getGithubApiClient() *github.Client {
app_id_string := os.Getenv("GH_APP_ID")
app_install_string := os.Getenv("GH_APP_INSTALL_ID")
key_string := os.Getenv("GH_APP_PRIVATE_KEY")

if app_id_string == "" || app_install_string == "" || key_string == "" {
log.Fatalf("GH_APP_ID, GH_APP_INSTALL_ID, and GH_APP_PRIVATE_KEY env variables must be set")
}

app_id, err := strconv.ParseInt(app_id_string, 10, 64)
if err != nil {
log.Fatal("Invalid GH_APP_ID environment variable, must be integer")
}
app_install, err := strconv.ParseInt(app_install_string, 10, 64)
if err != nil {
log.Fatal("Invalid GH_APP_INSTALL_ID environment variable, must be integer")
}
key := []byte(key_string)

itr, err := ghinstallation.New(http.DefaultTransport, app_id, app_install, key)
if err != nil {
log.Fatal(err)
}
return github.NewClient(&http.Client{Transport: itr})
}

// check whether an issue has already been filed for the given PR, and file one if not.
func fileFollowupIssue(ctx context.Context, client *github.Client, repo, bugrepo string, prNum int) error {
title := fmt.Sprintf("TBR %s/%s/pull/%d followup review", org, repo, prNum)

// all of the followup issues are in corp, no matter the repo of the submitted PR
check := fmt.Sprintf("%s in:title repo:%s/%s", title, org, bugrepo)
followup, _, err := client.Search.Issues(ctx, check, &github.SearchOptions{
ListOptions: github.ListOptions{PerPage: 10}})
if err != nil {
return err
}

if len(followup.Issues) > 0 {
// Issue already filed, nothing more to do.
return nil
}

body := fmt.Sprintf("https://github.com/%s/%s/pull/%d was filed to-be-reviewed "+
"without any reviewer approving. Someone needs to review it, followup on any "+
"changes needed, and note completion by closing this issue.", org, repo, prNum)
req := github.IssueRequest{Title: &title, Body: &body}
_, _, err = client.Issues.Create(ctx, org, bugrepo, &req)
if err != nil {
return err
}

return nil
}

func payloadOrDie(event *github.Event) interface{} {
payload, err := event.ParsePayload()
if err != nil {
log.Fatalf("failed (%v) to parse: %v\n", err, event)
}
return payload
}

// The Activity API only includes State=APPROVED if the approval came within the last
// 300 events. PRs approved a long time before submission will not show as APPROVED
// in the PullRequestReviewEvent.
// To double-check, we use the API to check all comments of a Pull Request if any of them
// contained an Approval.
func wasPrEverApproved(client *github.Client, repo string, prNum int) bool {
opt := &github.ListOptions{PerPage: 100}
ctx := context.Background()
for {
reviews, resp, err := client.PullRequests.ListReviews(ctx, org, repo, prNum, opt)
if err != nil {
log.Fatal(err.Error())
}

for _, review := range reviews {
if strings.EqualFold(*review.State, "approved") {
return true
}
}

if resp.NextPage == 0 {
break
}
opt.Page = resp.NextPage
}

return false
}

func checkForToBeReviewed(client *github.Client, repo, bugrepo string) {
type PullRequestState struct {
Approved bool
Submitted bool
}
pulls := make(map[int]*PullRequestState, 50)

opt := &github.ListOptions{PerPage: 100}
ctx := context.Background()
for {
events, resp, err := client.Activity.ListRepositoryEvents(ctx, org, repo, opt)
if err != nil {
log.Fatal(err.Error())
}

for _, evt := range events {
typ := evt.GetType()
if typ == "PullRequestEvent" {
payload := payloadOrDie(evt).(*github.PullRequestEvent)
prNum := *payload.PullRequest.Number
if _, ok := pulls[prNum]; !ok {
pulls[prNum] = &PullRequestState{}
}
if strings.EqualFold(*payload.Action, "closed") &&
*payload.PullRequest.Merged {
pulls[prNum].Submitted = true
}
}
if typ == "PullRequestReviewEvent" {
payload := payloadOrDie(evt).(*github.PullRequestReviewEvent)
prNum := *payload.PullRequest.Number
if _, ok := pulls[prNum]; !ok {
pulls[prNum] = &PullRequestState{}
}
if strings.EqualFold(*payload.Review.State, "approved") {
pulls[prNum].Approved = true
}
}
}

if resp.NextPage == 0 {
break
}
opt.Page = resp.NextPage
}

for prNum, pr := range pulls {
if pr.Submitted && !pr.Approved {
// Double-check if there was ever an approval.
if wasPrEverApproved(client, repo, prNum) {
continue
}

// This Pull Request was submitted without an Approver.
err := fileFollowupIssue(ctx, client, repo, bugrepo, prNum)
if err != nil {
log.Fatal(err)
}
}
}
}

func main() {
flag.StringVar(&org, "org", "example-github-organization", "GitHub organization to use")
reposPtr := flag.String("repos", "example-github-repo",
"comma-separated list of GitHub repositories to check for to-be-reviewed PRs")
bugrepoPtr := flag.String("bugrepo", "ToBeReviewedBot",
"name of repository to file followup issues in")
flag.Parse()

client := getGithubApiClient()
for _, repo := range strings.Split(*reposPtr, ",") {
checkForToBeReviewed(client, repo, *bugrepoPtr)
}
}
22 changes: 22 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module github.com/tailscale/tbr-audit

go 1.17

require (
github.com/google/go-github/v42 v42.0.0
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
)

require (
github.com/bradleyfalzon/ghinstallation v1.1.1 // indirect
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.0.0 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/google/go-github/v29 v29.0.2 // indirect
github.com/google/go-github/v41 v41.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
google.golang.org/appengine v1.6.7 // indirect
)
44 changes: 44 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
github.com/bradleyfalzon/ghinstallation v1.1.1 h1:pmBXkxgM1WeF8QYvDLT5kuQiHMcmf+X015GI0KM/E3I=
github.com/bradleyfalzon/ghinstallation v1.1.1/go.mod h1:vyCmHTciHx/uuyN82Zc3rXN3X2KTK8nUTCrTMwAhcug=
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4 h1:tXKVfhE7FcSkhkv0UwkLvPDeZ4kz6OXd0PKPlFqf81M=
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4/go.mod h1:B40qPqJxWE0jDZgOR1JmaMy+4AY1eBP+IByOvqyAKp0=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts=
github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
github.com/google/go-github/v41 v41.0.0 h1:HseJrM2JFf2vfiZJ8anY2hqBjdfY1Vlj/K27ueww4gg=
github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg=
github.com/google/go-github/v42 v42.0.0 h1:YNT0FwjPrEysRkLIiKuEfSvBPCGKphW5aS5PxwaoLec=
github.com/google/go-github/v42 v42.0.0/go.mod h1:jgg/jvyI0YlDOM1/ps6XYh04HNQ3vKf0CVko62/EhRg=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

0 comments on commit 70b3a8f

Please sign in to comment.