Added support for automatic labeling of PRs.

This commit is contained in:
Russell Jones
2021-12-23 11:43:56 -08:00
committed by Russell Jones
parent bcba332c4d
commit a3d85ab5fe
7 changed files with 290 additions and 3 deletions
+1 -1
View File
@@ -44,4 +44,4 @@ jobs:
uses: actions/setup-go@v2
# Run "dismiss" subcommand on bot.
- name: Dismiss
run: cd .github/workflows/robot && go run main.go -workflow=dismiss -token="${{ secrets.GITHUB_TOKEN }}" -reviewers="${{ secrets.reviewers }}"
run: cd .github/workflows/robot && go run main.go -workflow=dismiss -token="${{ secrets.GITHUB_TOKEN }}"
+46
View File
@@ -0,0 +1,46 @@
# This workflow is run whenever a pull request is opened, re-opened, or taken
# out of draft (ready for review).
#
# NOTE: pull_request_target behaves the same as pull_request except it grants a
# read/write token to workflows running on a pull request from a fork. While
# this may seem unsafe, the permissions for the token are limited below and
# the permissions can not be changed without merging to master which is
# protected by CODEOWNERS.
name: Label
on:
pull_request_target:
types: [opened, ready_for_review]
# Limit the permissions on the GitHub token for this workflow to the subset
# that is required. In this case, the label workflow only needs to be able
# to update labels on the PR, so it needs write access to "pull-requests",
# nothing else.
permissions:
pull-requests: write
actions: none
checks: none
contents: none
deployments: none
issues: none
packages: none
repository-projects: none
security-events: none
statuses: none
jobs:
auto-label-pr:
name: Label Pull Request
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
# Checkout master branch of Teleport repository. This is to prevent an
# attacker from submitting their own bot logic.
- name: Checkout master branch
uses: actions/checkout@v2
with:
ref: master
- name: Installing the latest version of Go.
uses: actions/setup-go@v2
# Run "label" subcommand on bot.
- name: Labeling PR
run: cd .github/workflows/robot && go run main.go -workflow=label -token="${{ secrets.GITHUB_TOKEN }}"
@@ -109,6 +109,10 @@ func (f *fakeGithub) ListFiles(ctx context.Context, organization string, reposit
return f.files, nil
}
func (f *fakeGithub) AddLabels(ctx context.Context, organization string, repository string, number int, labels []string) error {
return nil
}
func (f *fakeGithub) ListWorkflows(ctx context.Context, organization string, repository string) ([]github.Workflow, error) {
return nil, nil
}
@@ -0,0 +1,120 @@
/*
Copyright 2021 Gravitational, Inc.
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.
*/
package bot
import (
"context"
"log"
"strings"
"github.com/gravitational/trace"
)
// Label parses the content of the PR (branch name, files, etc) and sets
// appropriate labels.
func (b *Bot) Label(ctx context.Context) error {
labels, err := b.labels(ctx)
if err != nil {
return trace.Wrap(err)
}
err = b.c.GitHub.AddLabels(ctx,
b.c.Environment.Organization,
b.c.Environment.Repository,
b.c.Environment.Number,
labels)
if err != nil {
return trace.Wrap(err)
}
return nil
}
func (b *Bot) labels(ctx context.Context) ([]string, error) {
var labels []string
// The branch name is unsafe, but here we are simply adding a label.
if strings.HasPrefix(b.c.Environment.UnsafeBranch, "branch/") {
log.Println("Label: Found backport branch.")
labels = append(labels, "backport")
}
files, err := b.c.GitHub.ListFiles(ctx,
b.c.Environment.Organization,
b.c.Environment.Repository,
b.c.Environment.Number)
if err != nil {
return nil, trace.Wrap(err)
}
for _, file := range files {
// Special case: skip vendor until it is removed in #9545.
if strings.HasPrefix(file, "vendor/") {
continue
}
for k, v := range prefixes {
if strings.HasPrefix(file, k) {
log.Printf("Label: Found prefix %v, attaching labels: %v.", k, v)
labels = append(labels, v...)
}
}
for k, v := range suffixes {
if strings.HasSuffix(file, k) {
log.Printf("Label: Found suffix %v, attaching labels: %v.", k, v)
labels = append(labels, v...)
}
}
}
return deduplicate(labels), nil
}
func deduplicate(s []string) []string {
m := map[string]bool{}
for _, v := range s {
m[v] = true
}
var out []string
for k, _ := range m {
out = append(out, k)
}
return out
}
var prefixes map[string][]string = map[string][]string{
"bpf/": []string{"bpf"},
"docs/": []string{"documentation"},
"rfd/": []string{"documentation", "rfd"},
"examples/chart": []string{"helm"},
"lib/bpf/": []string{"bpf"},
"lib/kube": []string{"kubernetes"},
"lib/srv/desktop": []string{"desktop-access"},
"lib/srv/desktop/rdp": []string{"desktop-access", "rdp"},
"lib/srv/app/": []string{"application-access"},
"lib/srv/db": []string{"database-access"},
"lib/web/desktop.go": []string{"desktop-access"},
"tool/tctl/": []string{"tctl"},
"tool/tsh/": []string{"tsh"},
}
var suffixes map[string][]string = map[string][]string{
".md": []string{"documentation"},
".mdx": []string{"documentation"},
}
@@ -0,0 +1,98 @@
/*
Copyright 2021 Gravitational, Inc.
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.
*/
package bot
import (
"context"
"testing"
"github.com/gravitational/teleport/.github/workflows/robot/internal/env"
"github.com/stretchr/testify/require"
)
// TestLabel checks that labels are correctly applied to a Pull Request.
func TestLabel(t *testing.T) {
tests := []struct {
desc string
branch string
files []string
labels []string
}{
{
desc: "code-only",
branch: "foo",
files: []string{
"file.go",
},
labels: []string{},
},
{
desc: "docs",
branch: "foo",
files: []string{
"docs/docs.md",
},
labels: []string{"documentation"},
},
{
desc: "helm",
branch: "foo",
files: []string{
"examples/chart/index.html",
},
labels: []string{"helm"},
},
{
desc: "docs-and-helm",
branch: "foo",
files: []string{
"docs/docs.md",
"examples/chart/index.html",
},
labels: []string{"documentation", "helm"},
},
{
desc: "docs-and-backport",
branch: "branch/foo",
files: []string{
"docs/docs.md",
},
labels: []string{"backport", "documentation"},
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
b := &Bot{
c: &Config{
Environment: &env.Environment{
Organization: "foo",
Repository: "bar",
Number: 0,
UnsafeBranch: test.branch,
},
GitHub: &fakeGithub{
test.files,
},
},
}
labels, err := b.labels(context.Background())
require.NoError(t, err)
require.ElementsMatch(t, labels, test.labels)
})
}
}
@@ -45,6 +45,9 @@ type Client interface {
// ListFiles is used to list all the files within a PR.
ListFiles(ctx context.Context, organization string, repository string, number int) ([]string, error)
// AddLabels will add labels to an Issue or Pull Request.
AddLabels(ctx context.Context, organization string, repository string, number int, labels []string) error
// ListWorkflows lists all workflows within a repository.
ListWorkflows(ctx context.Context, organization string, repository string) ([]Workflow, error)
@@ -215,6 +218,20 @@ func (c *client) ListFiles(ctx context.Context, organization string, repository
return files, nil
}
// AddLabels will add labels to an Issue or Pull Request.
func (c *client) AddLabels(ctx context.Context, organization string, repository string, number int, labels []string) error {
_, _, err := c.client.Issues.AddLabelsToIssue(ctx,
organization,
repository,
number,
labels)
if err != nil {
return trace.Wrap(err)
}
return nil
}
// Workflow contains information about a workflow.
type Workflow struct {
// ID of the workflow.
+4 -2
View File
@@ -58,6 +58,8 @@ func main() {
err = b.Check(ctx)
case "dismiss":
err = b.Dismiss(ctx)
case "label":
err = b.Label(ctx)
default:
err = trace.BadParameter("unknown workflow: %v", workflow)
}
@@ -82,8 +84,8 @@ func parseFlags() (string, string, string, error) {
if *token == "" {
return "", "", "", trace.BadParameter("token missing")
}
if *reviewers == "" {
return "", "", "", trace.BadParameter("reviewers missing")
if *reviewers == "" && (*workflow == "assign" || *workflow == "check") {
return "", "", "", trace.BadParameter("reviewers required for assign and check")
}
data, err := base64.StdEncoding.DecodeString(*reviewers)