From 3d0c6cc25babef8ac0f3c7500edcb4447477534a Mon Sep 17 00:00:00 2001 From: Hongkai Liu Date: Wed, 4 Sep 2019 03:18:44 -0400 Subject: [PATCH 1/2] Copy autoowners from release repo --- cmd/autoowners/README.md | 31 +++ cmd/autoowners/main.go | 445 ++++++++++++++++++++++++++++++++++++ cmd/autoowners/main_test.go | 386 +++++++++++++++++++++++++++++++ 3 files changed, 862 insertions(+) create mode 100644 cmd/autoowners/README.md create mode 100644 cmd/autoowners/main.go create mode 100644 cmd/autoowners/main_test.go diff --git a/cmd/autoowners/README.md b/cmd/autoowners/README.md new file mode 100644 index 00000000000..d56f2e78af0 --- /dev/null +++ b/cmd/autoowners/README.md @@ -0,0 +1,31 @@ +# Populating `OWNERS` and `OWNERS_ALIASES` + +This utility updates the OWNERS files from remote Openshift repositories. + +Usage: + populate-owners [repo-name-regex] + +Args: + [repo-name-regex] A go regex which which matches the repos to update, by default all repos are selected + +```console +$ go run main.go [repo-name-regex] +``` + +Or, equivalently, execute [`populate-owners.sh`](../../ci-operator/populate-owners.sh) from anywhere in this repository. + +Upstream repositories are calculated from `ci-operator/jobs/{organization}/{repository}`. +For example, the presence of [`ci-operator/jobs/openshift/origin`](../../ci-operator/jobs/openshift/origin) inserts [openshift/origin][] as an upstream repository. + +The `HEAD` branch for each upstream repository is pulled to extract its `OWNERS` and `OWNERS_ALIASES`. +If `OWNERS` is missing, the utility will ignore `OWNERS_ALIASES`, even if it is present upstream. + +Any aliases present in the upstream `OWNERS` file will be resolved to the set of usernames they represent in the associated +`OWNERS_ALIASES` file. The local `OWNERS` files will therefore not contain any alias names. This avoids any conflicts between +upstream alias names coming from different repos. + +The utility also iterates through the `ci-operator/{type}/{organization}/{repository}` for `{type}` in `config`, `jobs`, and `templates`, writing `OWNERS` to reflect the upstream configuration. +If the upstream did not have an `OWNERS` file, the utility removes the associated `ci-operator/*/{organization}/{repository}/OWNERS`. + +[openshift/origin]: https://github.com/openshift/origin +[openshift/installer]: https://github.com/openshift/installer diff --git a/cmd/autoowners/main.go b/cmd/autoowners/main.go new file mode 100644 index 00000000000..9ec932c5b67 --- /dev/null +++ b/cmd/autoowners/main.go @@ -0,0 +1,445 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v2" +) + +const ( + doNotEdit = "# DO NOT EDIT; this file is auto-generated using tools/populate-owners.\n" + ownersComment = "# See the OWNERS docs: https://git.k8s.io/community/contributors/guide/owners.md\n" + ownersAliasesComment = "# See the OWNERS_ALIASES docs: https://git.k8s.io/community/contributors/guide/owners.md#owners_aliases\n" +) + +// owners is copied from k8s.io/test-infra/prow/repoowners's Config +type owners struct { + Approvers []string `json:"approvers,omitempty" yaml:"approvers,omitempty"` + Reviewers []string `json:"reviewers,omitempty" yaml:"reviewers,omitempty"` + RequiredReviewers []string `json:"required_reviewers,omitempty" yaml:"required_reviewers,omitempty"` + Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` +} + +type aliases struct { + Aliases map[string][]string `json:"aliases,omitempty" yaml:"aliases,omitempty"` +} + +type orgRepo struct { + Directories []string `json:"directories,omitempty" yaml:"directories,omitempty"` + Organization string `json:"organization,omitempty" yaml:"organization,omitempty"` + Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` + Owners *owners `json:"owners,omitempty" yaml:"owners,omitempty"` + Aliases *aliases `json:"aliases,omitempty" yaml:"aliases,omitempty"` + Commit string `json:"commit,omitempty" yaml:"commit,omitempty"` +} + +func getRepoRoot(directory string) (root string, err error) { + initialDir, err := filepath.Abs(directory) + if err != nil { + return "", err + } + + path := initialDir + for { + info, err := os.Stat(filepath.Join(path, ".git")) + if err == nil { + if info.IsDir() { + break + } + } else if !os.IsNotExist(err) { + return "", err + } + + parent := filepath.Dir(path) + if parent == path { + return "", fmt.Errorf("no .git found under %q", initialDir) + } + + path = parent + } + + return path, nil +} + +func orgRepos(dir string) (orgRepos []*orgRepo, err error) { + matches, err := filepath.Glob(filepath.Join(dir, "*", "*")) + if err != nil { + return nil, err + } + sort.Strings(matches) + + orgRepos = make([]*orgRepo, 0, len(matches)) + for _, path := range matches { + relpath, err := filepath.Rel(dir, path) + if err != nil { + return nil, err + } + org, repo := filepath.Split(relpath) + org = strings.TrimSuffix(org, string(filepath.Separator)) + if org == "openshift" && repo == "release" { + continue + } + orgRepos = append(orgRepos, &orgRepo{ + Directories: []string{path}, + Organization: org, + Repository: repo, + }) + } + + return orgRepos, err +} + +func (orgRepo *orgRepo) String() string { + return fmt.Sprintf("%s/%s", orgRepo.Organization, orgRepo.Repository) +} + +func (orgRepo *orgRepo) getDirectories(dirs ...string) (err error) { + for _, dir := range dirs { + path := filepath.Join(dir, orgRepo.Organization, orgRepo.Repository) + info, err := os.Stat(path) + if err != nil { + return err + } + + if info.IsDir() { + orgRepo.Directories = append(orgRepo.Directories, path) + } + } + + return nil +} + +func (orgRepo *orgRepo) getOwners() (err error) { + err = orgRepo.getOwnersHTTP() + if err == nil { + return nil + } + fmt.Fprintf(os.Stderr, "%v\n", err) + + return orgRepo.getOwnersGit() +} + +// getOwnersHTTP is fast (just the two files we need), but only works +// on public repos unless you have an auth token. +func (orgRepo *orgRepo) getOwnersHTTP() (err error) { + commitURI := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/HEAD", orgRepo.Organization, orgRepo.Repository) + commitAccept := "application/vnd.github.VERSION.sha" + data, _, err := get(commitURI, commitAccept) + if err != nil { + return err + } + initialCommit := string(data) + + for _, filename := range []string{"OWNERS", "OWNERS_ALIASES"} { + uri := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/%s", orgRepo.Organization, orgRepo.Repository, filename) + data, status, err := get(uri, "") + if err != nil { + if status == 404 { + continue + } + return err + } + + var target interface{} + switch filename { + case "OWNERS": + target = &orgRepo.Owners + case "OWNERS_ALIASES": + target = &orgRepo.Aliases + default: + return fmt.Errorf("unrecognized filename %q", target) + } + err = yaml.Unmarshal(data, target) + if err != nil { + return fmt.Errorf("failed to parse %s: %v", uri, err) + } + } + + if orgRepo.Owners == nil && orgRepo.Aliases == nil { + return nil + } + + data, _, err = get(commitURI, commitAccept) + if err != nil { + return err + } + finalCommit := string(data) + if initialCommit == finalCommit { + orgRepo.Commit = initialCommit + return nil + } + + fmt.Fprintf( + os.Stderr, + "%s changed from %s to %s, trying again", + orgRepo.String(), + initialCommit, + finalCommit, + ) + return orgRepo.getOwnersHTTP() +} + +func get(uri, accept string) (data []byte, status int, err error) { + request, err := http.NewRequest("GET", uri, nil) + if err != nil { + return data, 0, err + } + + if accept != "" { + request.Header.Add("Accept", accept) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return data, 0, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return data, response.StatusCode, fmt.Errorf("failed to fetch %s: %v %s", uri, response.StatusCode, response.Status) + } + + data, err = ioutil.ReadAll(response.Body) + if err != nil { + return data, response.StatusCode, fmt.Errorf("failed to read %s: %v", uri, err) + } + + return data, response.StatusCode, nil +} + +// getOwnersGit is slow (the full HEAD tree), but it works for any +// private repository you have access to, assuming you've told GitHub +// about your SSH key(s). +func (orgRepo *orgRepo) getOwnersGit() (err error) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + gitURL := fmt.Sprintf("ssh://git@github.com/%s/%s.git", orgRepo.Organization, orgRepo.Repository) + cmd := exec.Command("git", "clone", "--depth=1", "--single-branch", gitURL, dir) + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return err + } + + return orgRepo.extractOwners(dir) +} + +func (orgRepo *orgRepo) extractOwners(repoRoot string) (err error) { + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Stderr = os.Stderr + cmd.Dir = repoRoot + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + err = cmd.Start() + if err != nil { + return err + } + hash, err := ioutil.ReadAll(stdout) + if err != nil { + return err + } + err = cmd.Wait() + if err != nil { + return err + } + orgRepo.Commit = strings.TrimSuffix(string(hash), "\n") + + data, err := ioutil.ReadFile(filepath.Join(repoRoot, "OWNERS")) + if err != nil { + return err + } + + err = yaml.Unmarshal(data, &orgRepo.Owners) + if err != nil { + return err + } + + data, err = ioutil.ReadFile(filepath.Join(repoRoot, "OWNERS_ALIASES")) + if err != nil { + return err + } + + err = yaml.Unmarshal(data, &orgRepo.Aliases) + if err != nil { + return err + } + + return nil +} + +func writeYAML(path string, data interface{}, prefix []string) (err error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) + if err != nil { + return err + } + defer file.Close() + + for _, line := range prefix { + _, err := file.Write([]byte(line)) + if err != nil { + return err + } + } + + encoder := yaml.NewEncoder(file) + return encoder.Encode(data) +} + +// insertStringSlice inserts a string slice into another string slice +// replacing the elements starting with the begin index up to the end +// index. The element at end index in the original slice will remain +// in the resulting slice. Returns a new slice with the elements +// replaced. If the begin index is larger than the end, or either of the +// indexes are out of range of the slice, the original slice is returned +// unmodified. +func insertStringSlice(insert []string, intoSlice []string, + begin int, end int) []string { + if begin > end || begin < 0 || end > len(intoSlice) { + return intoSlice + } + firstPart := intoSlice[:begin] + secondPart := append(insert, intoSlice[end:]...) + return append(firstPart, secondPart...) +} + +// resolveAliases resolves names in the list of owners that +// match one of the given aliases. Returns a list of owners +// with each alias replaced by the list of owners it represents. +func resolveAliases(aliases *aliases, owners []string) []string { + offset := 0 // Keeps track of how many new names we've inserted + for i, owner := range owners { + if aliasOwners, ok := aliases.Aliases[owner]; ok { + index := i + offset + owners = insertStringSlice(aliasOwners, owners, index, (index + 1)) + offset += len(aliasOwners) - 1 + } + } + return owners +} + +// resolveOwnerAliases checks whether the orgRepo includes any +// owner aliases, and attempts to resolve them to the appropriate +// set of owners. Returns an owners which replaces any +// matching aliases with the set of owner names belonging to that alias. +func (orgRepo *orgRepo) resolveOwnerAliases() *owners { + if orgRepo.Aliases == nil || len(orgRepo.Aliases.Aliases) == 0 { + return orgRepo.Owners + } + + return &owners{ + resolveAliases(orgRepo.Aliases, orgRepo.Owners.Approvers), + resolveAliases(orgRepo.Aliases, orgRepo.Owners.Reviewers), + orgRepo.Owners.RequiredReviewers, + orgRepo.Owners.Labels, + } +} + +func (orgRepo *orgRepo) writeOwners() (err error) { + for _, directory := range orgRepo.Directories { + path := filepath.Join(directory, "OWNERS") + if orgRepo.Owners == nil { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + continue + } + + err = writeYAML(path, orgRepo.resolveOwnerAliases(), []string{ + doNotEdit, + fmt.Sprintf( + "# from https://github.com/%s/%s/blob/%s/OWNERS\n", + orgRepo.Organization, + orgRepo.Repository, + orgRepo.Commit, + ), + ownersComment, + "\n", + }) + if err != nil { + return err + } + } + + return nil +} + +func pullOwners(directory string, pattern string) (err error) { + repoRoot, err := getRepoRoot(directory) + if err != nil { + return err + } + + operatorRoot := filepath.Join(repoRoot, "ci-operator") + orgRepos, err := orgRepos(filepath.Join(operatorRoot, "jobs")) + if err != nil { + return err + } + + config := filepath.Join(operatorRoot, "config") + templates := filepath.Join(operatorRoot, "templates") + for _, orgRepo := range orgRepos { + matched, _ := regexp.MatchString(pattern, orgRepo.Repository) + if !matched { + continue + } + err = orgRepo.getDirectories(config, templates) + if err != nil && !os.IsNotExist(err) { + return err + } + + err = orgRepo.getOwners() + if err != nil && !os.IsNotExist(err) { + return err + } + + err = orgRepo.writeOwners() + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "updated owners for %s\n", orgRepo.String()) + } + + return nil +} + +const ( + usage = `Update the OWNERS files from remote repositories. + +Usage: + %s [repo-name-regex] + +Args: + [repo-name-regex] A go regex which which matches the repos to update, by default all repos are selected + +` +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), usage, "populate-owners") + } + flag.Parse() + repoPattern := flag.Arg(0) + + err := pullOwners(".", repoPattern) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} diff --git a/cmd/autoowners/main_test.go b/cmd/autoowners/main_test.go new file mode 100644 index 00000000000..59e76fb2945 --- /dev/null +++ b/cmd/autoowners/main_test.go @@ -0,0 +1,386 @@ +package main + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "testing" +) + +func assertEqual(t *testing.T, actual, expected interface{}) { + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("unexpected result: %+v != %+v", actual, expected) + } +} + +func TestGetRepoRoot(t *testing.T) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + root := filepath.Join(dir, "root") + deep := filepath.Join(root, "a", "b", "c") + git := filepath.Join(root, ".git") + err = os.MkdirAll(deep, 0777) + if err != nil { + t.Fatal(err) + } + err = os.Mkdir(git, 0777) + if err != nil { + t.Fatal(err) + } + + t.Run("from inside the repository", func(t *testing.T) { + found, err := getRepoRoot(deep) + if err != nil { + t.Fatal(err) + } + if found != root { + t.Fatalf("unexpected root: %q != %q", found, root) + } + }) + + t.Run("from outside the repository", func(t *testing.T) { + _, err := getRepoRoot(dir) + if err == nil { + t.Fatal(err) + } + }) +} + +func TestOrgRepos(t *testing.T) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + repoAB := filepath.Join(dir, "a", "b") + repoCD := filepath.Join(dir, "c", "d") + err = os.MkdirAll(repoAB, 0777) + if err != nil { + t.Fatal(err) + } + err = os.MkdirAll(repoCD, 0777) + if err != nil { + t.Fatal(err) + } + + orgRepos, err := orgRepos(dir) + if err != nil { + t.Fatal(err) + } + + expected := []*orgRepo{ + { + Directories: []string{repoAB}, + Organization: "a", + Repository: "b", + }, + { + Directories: []string{repoCD}, + Organization: "c", + Repository: "d", + }, + } + + assertEqual(t, orgRepos, expected) +} + +func TestGetDirectories(t *testing.T) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + repoAB := filepath.Join(dir, "a", "b") + err = os.MkdirAll(repoAB, 0777) + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + input *orgRepo + expected *orgRepo + error *regexp.Regexp + }{ + { + name: "config exists", + input: &orgRepo{ + Directories: []string{"some/directory"}, + Organization: "a", + Repository: "b", + }, + expected: &orgRepo{ + Directories: []string{"some/directory", filepath.Join(dir, "a", "b")}, + Organization: "a", + Repository: "b", + }, + }, + { + name: "config does not exist", + input: &orgRepo{ + Directories: []string{"some/directory"}, + Organization: "c", + Repository: "d", + }, + expected: &orgRepo{ + Directories: []string{"some/directory"}, + Organization: "c", + Repository: "d", + }, + error: regexp.MustCompile("^stat .*/c/d: no such file or directory"), + }, + } { + t.Run(test.name, func(t *testing.T) { + err := test.input.getDirectories(dir) + if test.error == nil { + if err != nil { + t.Fatal(err) + } + } else if !test.error.MatchString(err.Error()) { + t.Fatalf("unexpected error: %v does not match %v", err, test.error) + } + + assertEqual(t, test.input, test.expected) + }) + } +} + +func TestExtractOwners(t *testing.T) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + err = ioutil.WriteFile(filepath.Join(dir, "README"), []byte("Hello, World!\n"), 0666) + if err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{ + {"git", "init"}, + {"git", "config", "user.name", "Test"}, + {"git", "config", "user.email", "test@test.org"}, + {"git", "add", "README"}, + {"git", "commit", "-m", "Begin versioning"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = []string{ // for stable commit hashes + "GIT_COMMITTER_DATE=1112911993 -0700", + "GIT_AUTHOR_DATE=1112911993 -0700", + } + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + t.Log(string(stdoutStderr)) + t.Fatal(err) + } + } + + for _, test := range []struct { + name string + setup string + expected *orgRepo + error *regexp.Regexp + }{ + { + name: "no OWNERS", + expected: &orgRepo{ + Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", + }, + error: regexp.MustCompile("^open .*/populate-owners-[0-9]*/OWNERS: no such file or directory"), + }, + { + name: "only OWNERS", + setup: "OWNERS", + expected: &orgRepo{ + Owners: &owners{Approvers: []string{"alice", "bob"}}, + Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", + }, + error: regexp.MustCompile("^open .*/populate-owners-[0-9]*/OWNERS_ALIASES: no such file or directory"), + }, + { + name: "OWNERS and OWNERS_ALIASES", + setup: "OWNERS_ALIASES", + expected: &orgRepo{ + Owners: &owners{Approvers: []string{"sig-alias"}}, + Aliases: &aliases{Aliases: map[string][]string{"sig-alias": {"alice", "bob"}}}, + Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + switch test.setup { + case "": // nothing to do + case "OWNERS": + err = ioutil.WriteFile( + filepath.Join(dir, "OWNERS"), + []byte("approvers:\n- alice\n- bob\n"), + 0666, + ) + if err != nil { + t.Fatal(err) + } + case "OWNERS_ALIASES": + err = ioutil.WriteFile( + filepath.Join(dir, "OWNERS"), + []byte("approvers:\n- sig-alias\n"), + 0666, + ) + if err != nil { + t.Fatal(err) + } + err = ioutil.WriteFile( + filepath.Join(dir, "OWNERS_ALIASES"), + []byte("aliases:\n sig-alias:\n - alice\n - bob\n"), + 0666, + ) + if err != nil { + t.Fatal(err) + } + default: + t.Fatalf("unrecognized setup: %q", test.setup) + } + + orgrepo := &orgRepo{} + err := orgrepo.extractOwners(dir) + if test.error == nil { + if err != nil { + t.Fatal(err) + } + } else if !test.error.MatchString(err.Error()) { + t.Fatalf("unexpected error: %v does not match %v", err, test.error) + } + + // Need to override the newly created commit to avoid test failure + orgrepo.Commit = test.expected.Commit + assertEqual(t, orgrepo, test.expected) + }) + } +} + +func TestInsertSlice(t *testing.T) { + // test replacing two elements of a slice + given := []string{"alice", "bob", "carol", "david", "emily"} + expected := []string{"alice", "bob", "charlie", "debbie", "emily"} + actual := insertStringSlice([]string{"charlie", "debbie"}, given, 2, 4) + assertEqual(t, actual, expected) + + // test replacing all elements after the first + expected = []string{"alice", "eddie"} + actual = insertStringSlice([]string{"eddie"}, given, 1, len(given)) + assertEqual(t, actual, expected) + + // test invalid begin and end indexes, should return the slice unmodified + actual = insertStringSlice([]string{}, given, 5, 2) + assertEqual(t, given, given) + actual = insertStringSlice([]string{}, given, -1, 2) + assertEqual(t, given, given) + actual = insertStringSlice([]string{}, given, 1, len(given)+1) + assertEqual(t, given, given) +} + +func TestResolveAliases(t *testing.T) { + given := &orgRepo{ + Owners: &owners{Approvers: []string{"alice", "sig-alias", "david"}, + Reviewers: []string{"adam", "sig-alias"}}, + Aliases: &aliases{Aliases: map[string][]string{"sig-alias": {"bob", "carol"}}}, + } + expected := &orgRepo{ + Owners: &owners{Approvers: []string{"alice", "bob", "carol", "david"}, + Reviewers: []string{"adam", "bob", "carol"}}, + Aliases: &aliases{Aliases: map[string][]string{"sig-alias": {"bob", "carol"}}}, + } + assertEqual(t, given.resolveOwnerAliases(), expected.Owners) +} + +func TestWriteYAML(t *testing.T) { + dir, err := ioutil.TempDir("", "populate-owners-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + for _, test := range []struct { + name string + filename string + data interface{} + expected string + }{ + { + name: "OWNERS", + filename: "OWNERS", + data: &owners{ + Approvers: []string{"alice", "bob"}, + }, + expected: `# prefix 1 +# prefix 2 + +approvers: +- alice +- bob +`, + }, + { + name: "OWNERS overwrite", + filename: "OWNERS", + data: &owners{ + Approvers: []string{"bob", "charlie"}, + }, + expected: `# prefix 1 +# prefix 2 + +approvers: +- bob +- charlie +`, + }, + { + name: "OWNERS_ALIASES", + filename: "OWNERS_ALIASES", + data: &aliases{ + Aliases: map[string][]string{ + "group-1": {"alice", "bob"}, + }, + }, + expected: `# prefix 1 +# prefix 2 + +aliases: + group-1: + - alice + - bob +`, + }, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(dir, test.filename) + err = writeYAML( + path, + test.data, + []string{"# prefix 1\n", "# prefix 2\n", "\n"}, + ) + if err != nil { + t.Fatal(err) + } + + data, err := ioutil.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if string(data) != test.expected { + t.Fatalf("unexpected result:\n---\n%s\n--- != ---\n%s\n---\n", string(data), test.expected) + } + }) + } +} From 49a089308df4b16fc819669faed15ae124067d56 Mon Sep 17 00:00:00 2001 From: Hongkai Liu Date: Wed, 4 Sep 2019 05:21:26 -0400 Subject: [PATCH 2/2] Implement auto-owners --- cmd/autoowners/README.md | 2 + cmd/autoowners/main.go | 352 +++++++++++++++++------------------ cmd/autoowners/main_test.go | 139 +++----------- images/autoowners/Dockerfile | 7 + vendor/modules.txt | 18 +- 5 files changed, 215 insertions(+), 303 deletions(-) create mode 100644 images/autoowners/Dockerfile diff --git a/cmd/autoowners/README.md b/cmd/autoowners/README.md index d56f2e78af0..464b585b7e6 100644 --- a/cmd/autoowners/README.md +++ b/cmd/autoowners/README.md @@ -1,5 +1,7 @@ # Populating `OWNERS` and `OWNERS_ALIASES` +[comment]: <> (TODO: hongkliu: update this file) + This utility updates the OWNERS files from remote Openshift repositories. Usage: diff --git a/cmd/autoowners/main.go b/cmd/autoowners/main.go index 9ec932c5b67..fd1f7797ec3 100644 --- a/cmd/autoowners/main.go +++ b/cmd/autoowners/main.go @@ -3,31 +3,34 @@ package main import ( "flag" "fmt" - "io/ioutil" - "net/http" "os" - "os/exec" "path/filepath" - "regexp" "sort" "strings" + "time" - "gopkg.in/yaml.v2" + "github.com/ghodss/yaml" + "github.com/sirupsen/logrus" + + "k8s.io/test-infra/experiment/autobumper/bumper" + "k8s.io/test-infra/prow/config/secret" + "k8s.io/test-infra/prow/flagutil" + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/repoowners" ) const ( - doNotEdit = "# DO NOT EDIT; this file is auto-generated using tools/populate-owners.\n" - ownersComment = "# See the OWNERS docs: https://git.k8s.io/community/contributors/guide/owners.md\n" - ownersAliasesComment = "# See the OWNERS_ALIASES docs: https://git.k8s.io/community/contributors/guide/owners.md#owners_aliases\n" + doNotEdit = "# DO NOT EDIT; this file is auto-generated using tools/populate-owners.\n" + ownersComment = "# See the OWNERS docs: https://git.k8s.io/community/contributors/guide/owners.md\n" + //ownersAliasesComment = "# See the OWNERS_ALIASES docs: https://git.k8s.io/community/contributors/guide/owners.md#owners_aliases\n" + + githubOrg = "openshift" + githubRepo = "release" + githubLogin = "openshift-bot" + githubTeam = "openshift/openshift-team-developer-productivity-test-platform" ) -// owners is copied from k8s.io/test-infra/prow/repoowners's Config -type owners struct { - Approvers []string `json:"approvers,omitempty" yaml:"approvers,omitempty"` - Reviewers []string `json:"reviewers,omitempty" yaml:"reviewers,omitempty"` - RequiredReviewers []string `json:"required_reviewers,omitempty" yaml:"required_reviewers,omitempty"` - Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` -} +type owners = repoowners.Config type aliases struct { Aliases map[string][]string `json:"aliases,omitempty" yaml:"aliases,omitempty"` @@ -118,35 +121,22 @@ func (orgRepo *orgRepo) getDirectories(dirs ...string) (err error) { return nil } -func (orgRepo *orgRepo) getOwners() (err error) { - err = orgRepo.getOwnersHTTP() - if err == nil { - return nil - } - fmt.Fprintf(os.Stderr, "%v\n", err) - - return orgRepo.getOwnersGit() -} - // getOwnersHTTP is fast (just the two files we need), but only works // on public repos unless you have an auth token. func (orgRepo *orgRepo) getOwnersHTTP() (err error) { - commitURI := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/HEAD", orgRepo.Organization, orgRepo.Repository) - commitAccept := "application/vnd.github.VERSION.sha" - data, _, err := get(commitURI, commitAccept) + sc, err := gc.GetSingleCommit(orgRepo.Organization, orgRepo.Repository, "HEAD") if err != nil { return err } - initialCommit := string(data) for _, filename := range []string{"OWNERS", "OWNERS_ALIASES"} { - uri := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/%s", orgRepo.Organization, orgRepo.Repository, filename) - data, status, err := get(uri, "") + data, err := gc.GetFile(orgRepo.Organization, orgRepo.Repository, filename, "HEAD") if err != nil { - if status == 404 { + if _, nf := err.(*github.FileNotFound); nf { continue + } else { + return err } - return err } var target interface{} @@ -160,7 +150,11 @@ func (orgRepo *orgRepo) getOwnersHTTP() (err error) { } err = yaml.Unmarshal(data, target) if err != nil { - return fmt.Errorf("failed to parse %s: %v", uri, err) + logrus.WithField("data", string(data)).WithField("filename", filename). + WithField("orgRepo.Organization", orgRepo.Organization). + WithField("orgRepo.Repository", orgRepo.Repository). + WithError(err).Error("Unable to parse data.") + return err } } @@ -168,126 +162,22 @@ func (orgRepo *orgRepo) getOwnersHTTP() (err error) { return nil } - data, _, err = get(commitURI, commitAccept) - if err != nil { - return err - } - finalCommit := string(data) - if initialCommit == finalCommit { - orgRepo.Commit = initialCommit - return nil - } - - fmt.Fprintf( - os.Stderr, - "%s changed from %s to %s, trying again", - orgRepo.String(), - initialCommit, - finalCommit, - ) - return orgRepo.getOwnersHTTP() -} - -func get(uri, accept string) (data []byte, status int, err error) { - request, err := http.NewRequest("GET", uri, nil) - if err != nil { - return data, 0, err - } - - if accept != "" { - request.Header.Add("Accept", accept) - } - - response, err := http.DefaultClient.Do(request) - if err != nil { - return data, 0, err - } - defer response.Body.Close() - - if response.StatusCode != 200 { - return data, response.StatusCode, fmt.Errorf("failed to fetch %s: %v %s", uri, response.StatusCode, response.Status) - } - - data, err = ioutil.ReadAll(response.Body) - if err != nil { - return data, response.StatusCode, fmt.Errorf("failed to read %s: %v", uri, err) - } - - return data, response.StatusCode, nil -} - -// getOwnersGit is slow (the full HEAD tree), but it works for any -// private repository you have access to, assuming you've told GitHub -// about your SSH key(s). -func (orgRepo *orgRepo) getOwnersGit() (err error) { - dir, err := ioutil.TempDir("", "populate-owners-") - if err != nil { - return err - } - defer os.RemoveAll(dir) - - gitURL := fmt.Sprintf("ssh://git@github.com/%s/%s.git", orgRepo.Organization, orgRepo.Repository) - cmd := exec.Command("git", "clone", "--depth=1", "--single-branch", gitURL, dir) - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return err - } - - return orgRepo.extractOwners(dir) -} - -func (orgRepo *orgRepo) extractOwners(repoRoot string) (err error) { - cmd := exec.Command("git", "rev-parse", "HEAD") - cmd.Stderr = os.Stderr - cmd.Dir = repoRoot - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - err = cmd.Start() - if err != nil { - return err - } - hash, err := ioutil.ReadAll(stdout) - if err != nil { - return err - } - err = cmd.Wait() - if err != nil { - return err - } - orgRepo.Commit = strings.TrimSuffix(string(hash), "\n") - - data, err := ioutil.ReadFile(filepath.Join(repoRoot, "OWNERS")) - if err != nil { - return err - } - - err = yaml.Unmarshal(data, &orgRepo.Owners) - if err != nil { - return err - } - - data, err = ioutil.ReadFile(filepath.Join(repoRoot, "OWNERS_ALIASES")) - if err != nil { - return err - } - - err = yaml.Unmarshal(data, &orgRepo.Aliases) - if err != nil { - return err - } - + orgRepo.Commit = sc.Commit.Tree.SHA return nil + } -func writeYAML(path string, data interface{}, prefix []string) (err error) { +func writeYAML(path string, data interface{}, prefix []string) (rerr error) { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) if err != nil { return err } - defer file.Close() + defer func() { + err := file.Close() + if err != nil { + rerr = err + } + }() for _, line := range prefix { _, err := file.Write([]byte(line)) @@ -296,8 +186,14 @@ func writeYAML(path string, data interface{}, prefix []string) (err error) { } } - encoder := yaml.NewEncoder(file) - return encoder.Encode(data) + // https://github.com/ghodss/yaml + // respects the tags for json + bytes, err := yaml.Marshal(data) + if err != nil { + return err + } + _, err = file.Write(bytes) + return err } // insertStringSlice inserts a string slice into another string slice @@ -342,21 +238,25 @@ func (orgRepo *orgRepo) resolveOwnerAliases() *owners { } return &owners{ - resolveAliases(orgRepo.Aliases, orgRepo.Owners.Approvers), - resolveAliases(orgRepo.Aliases, orgRepo.Owners.Reviewers), - orgRepo.Owners.RequiredReviewers, - orgRepo.Owners.Labels, + Approvers: resolveAliases(orgRepo.Aliases, orgRepo.Owners.Approvers), + Reviewers: resolveAliases(orgRepo.Aliases, orgRepo.Owners.Reviewers), + RequiredReviewers: orgRepo.Owners.RequiredReviewers, + Labels: orgRepo.Owners.Labels, } } -func (orgRepo *orgRepo) writeOwners() (err error) { +func (orgRepo *orgRepo) writeOwners(whitelist []string) (err error) { for _, directory := range orgRepo.Directories { + if inWhitelist(directory, whitelist) { + logrus.WithField("directory", directory).Info("Ignoring the directory in the white list.") + continue + } path := filepath.Join(directory, "OWNERS") + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } if orgRepo.Owners == nil { - err := os.Remove(path) - if err != nil && !os.IsNotExist(err) { - return err - } continue } @@ -379,43 +279,43 @@ func (orgRepo *orgRepo) writeOwners() (err error) { return nil } -func pullOwners(directory string, pattern string) (err error) { +func pullOwners(directory string, whitelist []string) ([]string, error) { + var repos []string repoRoot, err := getRepoRoot(directory) if err != nil { - return err + return repos, err } operatorRoot := filepath.Join(repoRoot, "ci-operator") orgRepos, err := orgRepos(filepath.Join(operatorRoot, "jobs")) if err != nil { - return err + return repos, err } config := filepath.Join(operatorRoot, "config") templates := filepath.Join(operatorRoot, "templates") for _, orgRepo := range orgRepos { - matched, _ := regexp.MatchString(pattern, orgRepo.Repository) - if !matched { - continue - } + logrus.WithField("orgRepo", fmt.Sprintf("%+v", *orgRepo)).Info("handling repo ...") err = orgRepo.getDirectories(config, templates) if err != nil && !os.IsNotExist(err) { - return err + return repos, err } - err = orgRepo.getOwners() + err = orgRepo.getOwnersHTTP() if err != nil && !os.IsNotExist(err) { - return err + return repos, err } - err = orgRepo.writeOwners() + err = orgRepo.writeOwners(whitelist) if err != nil { - return err + return repos, err } - fmt.Fprintf(os.Stderr, "updated owners for %s\n", orgRepo.String()) + repoStr := orgRepo.String() + repos = append(repos, repoStr) + fmt.Fprintf(os.Stderr, "updated owners for %s\n", repoStr) } - return nil + return repos, err } const ( @@ -430,16 +330,110 @@ Args: ` ) +var ( + gc github.Client +) + +type options struct { + githubLogin string + githubToken string + gitName string + gitEmail string + assign string + targetDir string + whitelist flagutil.Strings +} + +func parseOptions() options { + var o options + flag.StringVar(&o.githubLogin, "github-login", githubLogin, "The GitHub username to use.") + flag.StringVar(&o.githubToken, "github-token", "", "The path to the GitHub token file.") + flag.StringVar(&o.gitName, "git-name", "", "The name to use on the git commit. Requires --git-email. If not specified, uses the system default.") + flag.StringVar(&o.gitEmail, "git-email", "", "The email to use on the git commit. Requires --git-name. If not specified, uses the system default.") + flag.StringVar(&o.assign, "assign", githubTeam, "The github username or group name to assign the created pull request to.") + flag.StringVar(&o.targetDir, "target-dir", "", "The directory containing the target repo.") + flag.Var(&o.whitelist, "ignore-repo", "The repo that syncing OWNERS file is disabled.") + flag.Parse() + return o +} + +func validateOptions(o options) error { + if o.githubLogin == "" { + return fmt.Errorf("--github-login is mandatory") + } + if o.githubToken == "" { + return fmt.Errorf("--github-token is mandatory") + } + if (o.gitEmail == "") != (o.gitName == "") { + return fmt.Errorf("--git-name and --git-email must be specified together") + } + if o.assign == "" { + return fmt.Errorf("--assign is mandatory") + } + if o.targetDir == "" { + return fmt.Errorf("--target-dir is mandatory") + } + return nil +} + +func inWhitelist(path string, whitelist []string) bool { + for _, e := range whitelist { + if strings.HasSuffix(path, e) { + return true + } + } + return false +} + +func getBody(repos []string, assign string) string { + body := "The OWNERS file has been synced for the following repo(s):\n\n" + for _, r := range repos { + body = fmt.Sprintf("%s* %s\n", body, r) + } + body = fmt.Sprintf("%s\n%s\n", body, "/cc @"+assign) + return body +} + +func getTitle(matchTitle, datetime string) string { + return fmt.Sprintf("%s by autoowners job at %s", matchTitle, datetime) +} + func main() { - flag.Usage = func() { - fmt.Fprintf(flag.CommandLine.Output(), usage, "populate-owners") + o := parseOptions() + if err := validateOptions(o); err != nil { + logrus.WithError(err).Fatal("Invalid arguments.") } - flag.Parse() - repoPattern := flag.Arg(0) - err := pullOwners(".", repoPattern) + secretAgent := &secret.Agent{} + if err := secretAgent.Start([]string{o.githubToken}); err != nil { + logrus.WithError(err).Fatalf("Error starting secrets agent.") + } + gc = github.NewClient(secretAgent.GetTokenGenerator(o.githubToken), secretAgent.Censor, github.DefaultGraphQLEndpoint, github.DefaultAPIEndpoint) + + repos, err := pullOwners(o.targetDir, o.whitelist.Strings()) + if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + logrus.WithError(err).Fatal("Error occurred when walking through the target dir.") + } + + if len(repos) == 0 { + logrus.Info("No OWNERS file to update, exiting ...") + return + } + + stdout := bumper.HideSecretsWriter{Delegate: os.Stdout, Censor: secretAgent} + stderr := bumper.HideSecretsWriter{Delegate: os.Stderr, Censor: secretAgent} + + remoteBranch := "autoowners" + if err := bumper.GitCommitAndPush(fmt.Sprintf("https://%s:%s@github.com/%s/%s.git", o.githubLogin, + string(secretAgent.GetTokenGenerator(o.githubToken)()), o.githubLogin, githubRepo), + remoteBranch, o.gitName, o.gitEmail, "", stdout, stderr); err != nil { + logrus.WithError(err).Fatal("Failed to push changes.") + } + + matchTitle := "Sync OWNERS files" + if err := bumper.UpdatePullRequest(gc, githubOrg, githubRepo, getTitle(matchTitle, time.Now().Format(time.RFC1123)), + getBody(repos, o.assign), matchTitle, o.githubLogin+":"+remoteBranch, "master"); err != nil { + logrus.WithError(err).Fatal("PR creation failed.") } } diff --git a/cmd/autoowners/main_test.go b/cmd/autoowners/main_test.go index 59e76fb2945..7373b11897d 100644 --- a/cmd/autoowners/main_test.go +++ b/cmd/autoowners/main_test.go @@ -3,7 +3,6 @@ package main import ( "io/ioutil" "os" - "os/exec" "path/filepath" "reflect" "regexp" @@ -154,120 +153,6 @@ func TestGetDirectories(t *testing.T) { } } -func TestExtractOwners(t *testing.T) { - dir, err := ioutil.TempDir("", "populate-owners-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) - - err = ioutil.WriteFile(filepath.Join(dir, "README"), []byte("Hello, World!\n"), 0666) - if err != nil { - t.Fatal(err) - } - - for _, args := range [][]string{ - {"git", "init"}, - {"git", "config", "user.name", "Test"}, - {"git", "config", "user.email", "test@test.org"}, - {"git", "add", "README"}, - {"git", "commit", "-m", "Begin versioning"}, - } { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = []string{ // for stable commit hashes - "GIT_COMMITTER_DATE=1112911993 -0700", - "GIT_AUTHOR_DATE=1112911993 -0700", - } - stdoutStderr, err := cmd.CombinedOutput() - if err != nil { - t.Log(string(stdoutStderr)) - t.Fatal(err) - } - } - - for _, test := range []struct { - name string - setup string - expected *orgRepo - error *regexp.Regexp - }{ - { - name: "no OWNERS", - expected: &orgRepo{ - Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", - }, - error: regexp.MustCompile("^open .*/populate-owners-[0-9]*/OWNERS: no such file or directory"), - }, - { - name: "only OWNERS", - setup: "OWNERS", - expected: &orgRepo{ - Owners: &owners{Approvers: []string{"alice", "bob"}}, - Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", - }, - error: regexp.MustCompile("^open .*/populate-owners-[0-9]*/OWNERS_ALIASES: no such file or directory"), - }, - { - name: "OWNERS and OWNERS_ALIASES", - setup: "OWNERS_ALIASES", - expected: &orgRepo{ - Owners: &owners{Approvers: []string{"sig-alias"}}, - Aliases: &aliases{Aliases: map[string][]string{"sig-alias": {"alice", "bob"}}}, - Commit: "3e7341c55330a127038bfc8d7a396d4951049b85", - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - switch test.setup { - case "": // nothing to do - case "OWNERS": - err = ioutil.WriteFile( - filepath.Join(dir, "OWNERS"), - []byte("approvers:\n- alice\n- bob\n"), - 0666, - ) - if err != nil { - t.Fatal(err) - } - case "OWNERS_ALIASES": - err = ioutil.WriteFile( - filepath.Join(dir, "OWNERS"), - []byte("approvers:\n- sig-alias\n"), - 0666, - ) - if err != nil { - t.Fatal(err) - } - err = ioutil.WriteFile( - filepath.Join(dir, "OWNERS_ALIASES"), - []byte("aliases:\n sig-alias:\n - alice\n - bob\n"), - 0666, - ) - if err != nil { - t.Fatal(err) - } - default: - t.Fatalf("unrecognized setup: %q", test.setup) - } - - orgrepo := &orgRepo{} - err := orgrepo.extractOwners(dir) - if test.error == nil { - if err != nil { - t.Fatal(err) - } - } else if !test.error.MatchString(err.Error()) { - t.Fatalf("unexpected error: %v does not match %v", err, test.error) - } - - // Need to override the newly created commit to avoid test failure - orgrepo.Commit = test.expected.Commit - assertEqual(t, orgrepo, test.expected) - }) - } -} - func TestInsertSlice(t *testing.T) { // test replacing two elements of a slice given := []string{"alice", "bob", "carol", "david", "emily"} @@ -384,3 +269,27 @@ aliases: }) } } + +func TestGetTitle(t *testing.T) { + expect := "Sync OWNERS files by autoowners job at Thu, 12 Sep 2019 14:56:10 EDT" + result := getTitle("Sync OWNERS files", "Thu, 12 Sep 2019 14:56:10 EDT") + + if expect != result { + t.Errorf("title '%s' differs from expected '%s'", result, expect) + } +} + +func TestGetBody(t *testing.T) { + expect := `The OWNERS file has been synced for the following repo(s): + +* openshift/origin +* org/repo + +/cc @openshift/openshift-team-developer-productivity-test-platform +` + result := getBody([]string{"openshift/origin", "org/repo"}, githubTeam) + + if expect != result { + t.Errorf("body '%s' differs from expected '%s'", result, expect) + } +} diff --git a/images/autoowners/Dockerfile b/images/autoowners/Dockerfile new file mode 100644 index 00000000000..efe2522b7d8 --- /dev/null +++ b/images/autoowners/Dockerfile @@ -0,0 +1,7 @@ +FROM centos:7 + +ADD autoowners /usr/bin/autoowners + +RUN yum install -y git + +ENTRYPOINT ["/usr/bin/autoowners"] diff --git a/vendor/modules.txt b/vendor/modules.txt index 10cbb6052cb..b31661cb6ac 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -400,6 +400,7 @@ k8s.io/client-go/testing k8s.io/client-go/kubernetes/scheme k8s.io/client-go/kubernetes/typed/core/v1/fake k8s.io/client-go/tools/remotecommand +k8s.io/client-go/kubernetes k8s.io/client-go/discovery k8s.io/client-go/util/flowcontrol k8s.io/client-go/tools/reference @@ -414,7 +415,6 @@ k8s.io/client-go/tools/auth k8s.io/client-go/tools/clientcmd/api/latest k8s.io/client-go/util/homedir k8s.io/client-go/tools/record/util -k8s.io/client-go/kubernetes k8s.io/client-go/discovery/fake k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1 k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1/fake @@ -486,22 +486,22 @@ k8s.io/client-go/kubernetes/typed/storage/v1beta1 k8s.io/client-go/kubernetes/typed/storage/v1beta1/fake k8s.io/client-go/transport/spdy k8s.io/client-go/util/exec +k8s.io/client-go/plugin/pkg/client/auth +k8s.io/client-go/util/workqueue k8s.io/client-go/pkg/apis/clientauthentication k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1 k8s.io/client-go/pkg/apis/clientauthentication/v1beta1 k8s.io/client-go/util/connrotation k8s.io/client-go/util/keyutil k8s.io/client-go/tools/clientcmd/api/v1 -k8s.io/client-go/plugin/pkg/client/auth -k8s.io/client-go/util/workqueue -k8s.io/client-go/tools/cache k8s.io/client-go/plugin/pkg/client/auth/azure k8s.io/client-go/plugin/pkg/client/auth/gcp k8s.io/client-go/plugin/pkg/client/auth/oidc k8s.io/client-go/plugin/pkg/client/auth/openstack +k8s.io/client-go/tools/cache +k8s.io/client-go/util/jsonpath k8s.io/client-go/dynamic k8s.io/client-go/tools/pager -k8s.io/client-go/util/jsonpath k8s.io/client-go/third_party/forked/golang/template # k8s.io/klog v0.4.0 k8s.io/klog @@ -511,13 +511,14 @@ k8s.io/kube-openapi/pkg/util/proto k8s.io/test-infra/experiment/autobumper/bumper k8s.io/test-infra/prow/config/secret k8s.io/test-infra/prow/github +k8s.io/test-infra/prow/flagutil +k8s.io/test-infra/prow/repoowners k8s.io/test-infra/prow/apis/prowjobs/v1 k8s.io/test-infra/prow/pod-utils/downwardapi k8s.io/test-infra/prow/config k8s.io/test-infra/prow/hook k8s.io/test-infra/prow/plugins k8s.io/test-infra/prow/plugins/updateconfig -k8s.io/test-infra/prow/flagutil k8s.io/test-infra/prow/client/clientset/versioned k8s.io/test-infra/prow/client/clientset/versioned/fake k8s.io/test-infra/prow/client/clientset/versioned/typed/prowjobs/v1 @@ -528,9 +529,10 @@ k8s.io/test-infra/experiment/image-bumper/bumper k8s.io/test-infra/robots/pr-creator/updater k8s.io/test-infra/ghproxy/ghcache k8s.io/test-infra/prow/errorutil -k8s.io/test-infra/prow/apis/prowjobs +k8s.io/test-infra/prow/bugzilla k8s.io/test-infra/prow/git k8s.io/test-infra/prow/kube +k8s.io/test-infra/prow/apis/prowjobs k8s.io/test-infra/prow/plugins/approve k8s.io/test-infra/prow/plugins/assign k8s.io/test-infra/prow/plugins/blockade @@ -575,11 +577,9 @@ k8s.io/test-infra/prow/plugins/verify-owners k8s.io/test-infra/prow/plugins/welcome k8s.io/test-infra/prow/plugins/wip k8s.io/test-infra/prow/plugins/yuks -k8s.io/test-infra/prow/bugzilla k8s.io/test-infra/prow/commentpruner k8s.io/test-infra/prow/labels k8s.io/test-infra/prow/pluginhelp -k8s.io/test-infra/prow/repoowners k8s.io/test-infra/prow/slack k8s.io/test-infra/prow/client/clientset/versioned/typed/prowjobs/v1/fake k8s.io/test-infra/prow/client/clientset/versioned/scheme