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

project-infra, botreview: Initial implementation of prow plugin to automate simple reviews #2448

Merged
merged 17 commits into from
Dec 7, 2023
Merged
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
42 changes: 42 additions & 0 deletions external-plugins/botreview/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
name = "go_default_library",
srcs = ["main.go"],
importpath = "kubevirt.io/project-infra/external-plugins/botreview",
visibility = ["//visibility:private"],
deps = [
"//external-plugins/botreview/server:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@io_k8s_test_infra//pkg/flagutil:go_default_library",
"@io_k8s_test_infra//prow/config/secret:go_default_library",
"@io_k8s_test_infra//prow/flagutil:go_default_library",
"@io_k8s_test_infra//prow/interrupts:go_default_library",
"@io_k8s_test_infra//prow/pluginhelp/externalplugins:go_default_library",
],
)

go_binary(
name = "botreview",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)

load("@io_bazel_rules_docker//go:image.bzl", "go_image")

go_image(
name = "app",
base = "@infra-base//image",
embed = [":go_default_library"],
)

load("@io_bazel_rules_docker//container:container.bzl", "container_push")

container_push(
name = "push",
format = "Docker",
image = ":app",
registry = "quay.io",
repository = "kubevirtci/botreview",
tag = "{DOCKER_TAG}",
)
16 changes: 16 additions & 0 deletions external-plugins/botreview/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

.PHONY: all clean format test push
all: format test push
bazelbin := bazelisk

build:
$(bazelbin) build //external-plugins/botreview/...

format:
gofmt -w .

test:
$(bazelbin) test //external-plugins/botreview/...

push:
podman build -f ../../images/botreview/Containerfile -t quay.io/kubevirtci/botreview:latest && podman push quay.io/kubevirtci/botreview:latest
13 changes: 13 additions & 0 deletions external-plugins/botreview/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
botreview plugin
================

Automates "simple reviews", meaning reviews that have been created by automation and where a review process can be easily put into code.

Motivation
----------
Most of the time `ci-maintainers` are looking at PRs that have been created by some kind of automation, i.e. the prow update mechanism, the prowjob image update, the kubevirtci bump, etc.
Updates in these PRs are mostly tedious to review for a human, since they contain lengthy repeated updates to some URLs or some image reference. A human could only look at these changes and try to manually spot errors in the references, which first of all is hard and second is already covered by the prow-deploy-presubmit.

What `botreview` can at least do is automate what a human would do anyway, like applying an expected change pattern to the changes. And this is what botreview does.

`botreview` has of course room for improvement, i.e. it might generate a list of the images and check whether these are pullable, or even perform further checks on the images. **Note: the latter is not implemented (yet)**
123 changes: 123 additions & 0 deletions external-plugins/botreview/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* This file is part of the KubeVirt project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Copyright the KubeVirt Authors.
*/

package main

import (
"flag"
"fmt"
"github.com/sirupsen/logrus"
"k8s.io/test-infra/pkg/flagutil"
"k8s.io/test-infra/prow/config/secret"
prowflagutil "k8s.io/test-infra/prow/flagutil"
"k8s.io/test-infra/prow/interrupts"
"k8s.io/test-infra/prow/pluginhelp/externalplugins"
"kubevirt.io/project-infra/external-plugins/botreview/server"
"net/http"
"os"
"strconv"
"time"
)

const pluginName = "botreview"

func init() {
logrus.SetFormatter(&logrus.JSONFormatter{})
logrus.SetLevel(logrus.DebugLevel)
}

type options struct {
port int

dryRun bool
github prowflagutil.GitHubOptions
labels prowflagutil.Strings

webhookSecretFile string
}

func (o *options) Validate() error {
for idx, group := range []flagutil.OptionGroup{&o.github} {
if err := group.Validate(o.dryRun); err != nil {
return fmt.Errorf("%d: %w", idx, err)
}
}

return nil
}

func gatherOptions() options {
o := options{}
fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
fs.IntVar(&o.port, "port", 8888, "Port to listen on.")
fs.BoolVar(&o.dryRun, "dry-run", true, "Dry run for testing. Uses API tokens but does not mutate.")
fs.StringVar(&o.webhookSecretFile, "hmac-secret-file", "/etc/webhook/hmac", "Path to the file containing the GitHub HMAC secret.")
for _, group := range []flagutil.OptionGroup{&o.github} {
group.AddFlags(fs)
}
fs.Parse(os.Args[1:])
return o
}

func main() {
o := gatherOptions()
if err := o.Validate(); err != nil {
logrus.Fatalf("Invalid options: %v", err)
}

log := logrus.StandardLogger().WithField("plugin", pluginName)

if err := secret.Add(o.github.TokenPath, o.webhookSecretFile); err != nil {
logrus.WithError(err).Fatal("Error starting secrets agent.")
}

githubClient := o.github.GitHubClientWithAccessToken(string(secret.GetSecret(o.github.TokenPath)))
brianmcarey marked this conversation as resolved.
Show resolved Hide resolved
gitClient, err := o.github.GitClient(o.dryRun)
if err != nil {
logrus.WithError(err).Fatal("Error getting Git client.")
}
interrupts.OnInterrupt(func() {
if err := gitClient.Clean(); err != nil {
logrus.WithError(err).Error("Could not clean up git client cache.")
}
})

botUserData, err := githubClient.BotUser()
if err != nil {
logrus.WithError(err).Fatal("Error getting bot name.")
}

pluginServer := &server.Server{
TokenGenerator: secret.GetTokenGenerator(o.webhookSecretFile),
BotName: botUserData.Name,

GitClient: gitClient,
Ghc: githubClient,
Log: log,

DryRun: o.dryRun,
}

mux := http.NewServeMux()
mux.Handle("/", pluginServer)
externalplugins.ServeExternalPluginHelp(mux, log, server.HelpProvider)
httpServer := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux}
defer interrupts.WaitForGracefulShutdown()
interrupts.ListenAndServe(httpServer, 5*time.Second)

}
32 changes: 32 additions & 0 deletions external-plugins/botreview/review/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = [
"bump_kubevirtci.go",
"image_update.go",
"prow_autobump.go",
"review.go",
],
importpath = "kubevirt.io/project-infra/external-plugins/botreview/review",
visibility = ["//visibility:public"],
deps = [
"@com_github_sirupsen_logrus//:go_default_library",
"@com_github_sourcegraph_go_diff//diff:go_default_library",
"@io_k8s_test_infra//prow/git:go_default_library",
"@io_k8s_test_infra//prow/github:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = [
"bump_kubevirtci_test.go",
"image_update_test.go",
"prow_autobump_test.go",
"review_test.go",
],
data = glob(["testdata/**"]),
embed = [":go_default_library"],
deps = ["@com_github_sourcegraph_go_diff//diff:go_default_library"],
)
166 changes: 166 additions & 0 deletions external-plugins/botreview/review/bump_kubevirtci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* This file is part of the KubeVirt project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Copyright the KubeVirt authors.
*
*/

package review

import (
"fmt"
"github.com/sourcegraph/go-diff/diff"
"regexp"
"strings"
)

const (
bumpKubevirtCIApproveComment = `:thumbsup: This looks like a simple kubevirtci bump.`
bumpKubevirtCIDisapproveComment = `:thumbsdown: This doesn't look like a simple kubevirtci bump.

I found suspicious hunks:
dhiller marked this conversation as resolved.
Show resolved Hide resolved
`
)

var bumpKubevirtCIHackConfigDefaultMatcher *regexp.Regexp
var bumpKubevirtCIClusterUpShaMatcher *regexp.Regexp
var bumpKubevirtCIClusterUpVersionMatcher *regexp.Regexp

func init() {
bumpKubevirtCIHackConfigDefaultMatcher = regexp.MustCompile(`(?m)^-[\s]*kubevirtci_git_hash=\"[^\s]+\"$[\n]^\+[\s]*kubevirtci_git_hash=\"[^\s]+\"$`)
bumpKubevirtCIClusterUpShaMatcher = regexp.MustCompile(`(?m)^-[\s]*[^\s]+$[\n]^\+[^\s]+$`)
bumpKubevirtCIClusterUpVersionMatcher = regexp.MustCompile(`(?m)^-[0-9]+-[a-z0-9]+$[\n]^\+[0-9]+-[a-z0-9]+$`)
}

type BumpKubevirtCIResult struct {
notMatchingHunks map[string][]*diff.Hunk
}

func (r BumpKubevirtCIResult) IsApproved() bool {
return len(r.notMatchingHunks) == 0
}

func (r BumpKubevirtCIResult) CanMerge() bool {
return true
}

func (r BumpKubevirtCIResult) String() string {
if r.IsApproved() {
return bumpKubevirtCIApproveComment
} else {
comment := bumpKubevirtCIDisapproveComment
for fileName, hunks := range r.notMatchingHunks {
dhiller marked this conversation as resolved.
Show resolved Hide resolved
comment += fmt.Sprintf("\nFile: `%s`", fileName)
for _, hunk := range hunks {
comment += fmt.Sprintf("\n```\n%s\n```", string(hunk.Body))
}
}
return comment
}
}

func (r *BumpKubevirtCIResult) AddReviewFailure(fileName string, hunks ...*diff.Hunk) {
if r.notMatchingHunks == nil {
r.notMatchingHunks = make(map[string][]*diff.Hunk)
}
if _, exists := r.notMatchingHunks[fileName]; !exists {
r.notMatchingHunks[fileName] = hunks
} else {
r.notMatchingHunks[fileName] = append(r.notMatchingHunks[fileName], hunks...)
}
}

func (r BumpKubevirtCIResult) ShortString() string {
if r.IsApproved() {
return bumpKubevirtCIApproveComment
} else {
comment := bumpKubevirtCIDisapproveComment
comment += fmt.Sprintf("\nFiles:")
for fileName := range r.notMatchingHunks {
comment += fmt.Sprintf("\n* `%s`", fileName)
}
return comment
}
}

type BumpKubevirtCI struct {
relevantFileDiffs []*diff.FileDiff
unwantedFiles map[string][]*diff.Hunk
}

func (t *BumpKubevirtCI) IsRelevant() bool {
return len(t.relevantFileDiffs) > 0
}

func (t *BumpKubevirtCI) AddIfRelevant(fileDiff *diff.FileDiff) {
fileName := strings.TrimPrefix(fileDiff.NewName, "b/")

// store all hunks for unwanted files
if fileName != "cluster-up-sha.txt" &&
fileName != "hack/config-default.sh" &&
!strings.HasPrefix(fileName, "cluster-up/") {
for _, hunk := range fileDiff.Hunks {
if t.unwantedFiles == nil {
t.unwantedFiles = make(map[string][]*diff.Hunk, 0)
}
_, exists := t.unwantedFiles[fileName]
if !exists {
t.unwantedFiles[fileName] = []*diff.Hunk{hunk}
} else {
t.unwantedFiles[fileName] = append(t.unwantedFiles[fileName], hunk)
}
}
return
}

t.relevantFileDiffs = append(t.relevantFileDiffs, fileDiff)
}

func (t *BumpKubevirtCI) Review() BotReviewResult {
result := &BumpKubevirtCIResult{}

for _, fileDiff := range t.relevantFileDiffs {
fileName := strings.TrimPrefix(fileDiff.NewName, "b/")
var matcher *regexp.Regexp
switch fileName {
case "cluster-up-sha.txt":
matcher = bumpKubevirtCIClusterUpShaMatcher
case "hack/config-default.sh":
matcher = bumpKubevirtCIHackConfigDefaultMatcher
case "cluster-up/version.txt":
matcher = bumpKubevirtCIClusterUpVersionMatcher
default:
// no checks since we can't do anything reasonable here
continue
}
if matcher != nil {
for _, hunk := range fileDiff.Hunks {
if !matcher.Match(hunk.Body) {
result.AddReviewFailure(fileDiff.NewName, hunk)
}
}
}
}

for fileName, unwantedFiles := range t.unwantedFiles {
result.AddReviewFailure(fileName, unwantedFiles...)
}

return result
}

func (t *BumpKubevirtCI) String() string {
return fmt.Sprintf("relevantFileDiffs: %v", t.relevantFileDiffs)
}
Loading