diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa40be2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.vscode/ +*.swp +*.pyc +*~ +/bazel-* +.DS_Store +.idea/ +_output/ +token diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6842ce7 --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +# Copyright 2023 The cert-manager Authors. +# +# 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. + +SHELL := /usr/bin/env bash + +# available for override +GITHUB_TOKEN_PATH ?= + +ORGS = $(shell find ./config -mindepth 1 -maxdepth 1 -type d | cut -d/ -f3) + +# use absolute path to ./_output, which is .gitignored +OUTPUT_DIR := $(shell pwd)/_output +OUTPUT_BIN_DIR := $(OUTPUT_DIR)/bin + +MERGE_CMD := $(OUTPUT_BIN_DIR)/merge +PERIBOLOS_CMD := $(OUTPUT_BIN_DIR)/peribolos + +CONFIG_FILES = $(shell find config/ -type f -name '*.yaml') +MERGED_CONFIG := $(OUTPUT_DIR)/gen-config.yaml + +# convenience targets for humans +.PHONY: clean +clean: + rm -rf $(OUTPUT_DIR) + +.PHONY: build +build: + go build ./... + +.PHONY: merge +merge: $(MERGE_CMD) + +.PHONY: config +config: $(MERGED_CONFIG) + +.PHONY: peribolos +peribolos: $(PERIBOLOS_CMD) + +.PHONY: verify +verify: verify-boilerplate verify-config + +.PHONY: verify-boilerplate +verify-boilerplate: + go run github.com/cert-manager/boilersuite@v0.1.0 . + +.PHONY: verify-config +verify-config: config + MERGED_CONFIG=$(MERGED_CONFIG) go test ./... + +.PHONY: update-prep +update-prep: config verify-config peribolos + +.PHONY: deploy # --confirm +deploy: + ./admin/update.sh + $(-*-command-variables-*-) $(filter-out $@,$(MAKECMDGOALS)) + +add-members: + ./hack/add-members.sh + +# actual targets that only get built if they don't already exist +$(MERGE_CMD): + mkdir -p "$(OUTPUT_BIN_DIR)" + go build -o "$(OUTPUT_BIN_DIR)" ./cmd/merge + +$(MERGED_CONFIG): clean $(MERGE_CMD) $(CONFIG_FILES) + mkdir -p "$(OUTPUT_DIR)" + $(MERGE_CMD) \ + --merge-teams \ + $(shell for o in $(ORGS); do echo "--org-part=$$o=config/$$o/org.yaml"; done) \ + > $(MERGED_CONFIG) + +$(PERIBOLOS_CMD): + GOBIN=$(OUTPUT_BIN_DIR) go install sigs.k8s.io/prow/cmd/peribolos@main diff --git a/README.md b/README.md index fc77606..ad68a9c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ -# org -Configuration for the cert-manager GitHub org +# cert-manager GitHub Organization + +This repository contains the metadata configuration for the cert-manager GitHub Organizations. +The data here is consumed by the [peribolos tool](https://github.com/kubernetes-sigs/prow/tree/ea10bd8144c3d988528011af024abe47ea8dabb2/cmd/peribolos) to manage GitHub organizations, teams and repos. + +## Contributing + +This repository is mostly managed by the cert-manager admins. If you need to request access to a team, please open an issue in the community repository: https://github.com/cert-manager/community. + +Instructions for cert-manager admins: +1. create a PR with the proposed changes +2. reviewers can dry-run the peribolos tool to see the changes that will be made +3. once the PR is approved and merged, an admin has to run the peribolos tool again to apply the changes + +See `./admin/README.md` for instructions on how to manually run the peribolos tool (only possible for cert-manager admins). diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..5abb3f2 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,64 @@ +# Apply kubernetes configuration + +Merge a PR that changes some `config/foo/org.yaml` and then run the following: +```shell +# Displays what it would do without making changes until you add the confirm flag +./admin/update.sh --github-token-path ~/path-to-my-token # --confirm +``` + +This will default to a dry-run mode, displaying what changes it intends to make without actually updating anything on github. +It will apply the change if you send it the `--confirm` flag. + +It also runs `make test` to validate the config. + +Assuming everything works the tool should output something like the following: +```console +{"client":"github","component":"peribolos","level":"info","msg":"Throttle(300, 100)","time":"2018-08-10T17:42:15-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes-incubator)","time":"2018-08-10T17:42:15-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes-incubator)","time":"2018-08-10T17:42:17-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-incubator, admin)","time":"2018-08-10T17:42:17-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-incubator, member)","time":"2018-08-10T17:42:17-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:17-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes-retired)","time":"2018-08-10T17:42:17-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes-retired)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-retired, admin)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-retired, member)","time":"2018-08-10T17:42:18-07:00"} +{"component":"peribolos","level":"info","msg":"Waiting for calebamiles to accept invitation to kubernetes-retired","time":"2018-08-10T17:42:18-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes-sigs)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"EditOrg(kubernetes-sigs, {steering-private@kubernetes.io Kubernetes SIGs Org for Kubernetes SIG-related work true true read false})","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes-sigs)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-sigs, admin)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-sigs, member)","time":"2018-08-10T17:42:18-07:00"} +{"component":"peribolos","level":"info","msg":"Waiting for calebamiles to accept invitation to kubernetes-sigs","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes-sigs, carolynvs)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes-sigs, jeremyrickard)","time":"2018-08-10T17:42:18-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes)","time":"2018-08-10T17:42:18-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes)","time":"2018-08-10T17:42:19-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes, admin)","time":"2018-08-10T17:42:19-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes, member)","time":"2018-08-10T17:42:19-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, ianychoi)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, akutz)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, gochist)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, jeremyrickard)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, fanzhangio)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, dvonthenen)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, rosti)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"RemoveOrgMembership(kubernetes, bart0sh)","time":"2018-08-10T17:42:21-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes-client)","time":"2018-08-10T17:42:21-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes-client)","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-client, admin)","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-client, member)","time":"2018-08-10T17:42:22-07:00"} +{"component":"peribolos","level":"info","msg":"Waiting for calebamiles to accept invitation to kubernetes-client","time":"2018-08-10T17:42:22-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"GetOrg(kubernetes-csi)","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgInvitations(kubernetes-csi)","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-csi, admin)","time":"2018-08-10T17:42:22-07:00"} +{"client":"github","component":"peribolos","level":"info","msg":"ListOrgMembers(kubernetes-csi, member)","time":"2018-08-10T17:42:23-07:00"} +{"component":"peribolos","level":"info","msg":"Waiting for calebamiles to accept invitation to kubernetes-csi","time":"2018-08-10T17:42:23-07:00"} +{"component":"peribolos","level":"info","msg":"Skipping team and team member configuration","time":"2018-08-10T17:42:23-07:00"} +``` + +Happy administering! diff --git a/admin/update.sh b/admin/update.sh new file mode 100755 index 0000000..d970125 --- /dev/null +++ b/admin/update.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +skip_license_check + +# Copyright 2018 The Kubernetes Authors. +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail +set -x + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +readonly REPO_ROOT + +readonly admins=( + caniszczyk + thelinuxfoundation + inteon + jakexks + JoshVanL + maelvls + munnerz + SgtCoDFish + ThatsMrTalbot + wallrj +) + +# this is the hourly token limit for the GitHub API +# if unset, the default is set in the peribolos code: https://github.com/kubernetes-sigs/prow/blob/0bca2f1416a9c15d75b9cee8704b56b38d5895c6/prow/cmd/peribolos/main.go#L41 +# if set to 0, rate limiting is disabled +readonly HOURLY_TOKENS=3000 + +cd "${REPO_ROOT}" +make update-prep +cmd="${REPO_ROOT}/_output/bin/peribolos" +args=( + --config-path="${REPO_ROOT}/_output/gen-config.yaml" + --fix-org + --fix-org-members + --fix-repos + --fix-teams + --fix-team-members + --fix-team-repos + --github-hourly-tokens="${HOURLY_TOKENS}" + "${admins[@]/#/--required-admins=}" +) + +"${cmd}" "${args[@]}" "${@}" diff --git a/cmd/korg/audit.go b/cmd/korg/audit.go new file mode 100644 index 0000000..9c698a6 --- /dev/null +++ b/cmd/korg/audit.go @@ -0,0 +1,374 @@ +// +skip_license_check +/* +Copyright 2023 The Kubernetes Authors. + +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 main + +import ( + "bufio" + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/olekukonko/tablewriter" +) + +type Contribution struct { + Rank int + Username string + ContribCount int + Orgs []string +} + +type Values struct { + Items [][]interface{} `json:"values,omitempty"` +} + +type Frames struct { + Schema map[string]interface{} `json:"schema,omitempty"` + Data Values `json:"data,omitempty"` +} + +type DevStatsRequest struct { + Queries []Query `json:"queries"` +} + +type Query struct { + RefID string `json:"refId"` + DatasourceID int `json:"datasourceId"` + RawSQL string `json:"rawSql"` + Format string `json:"format"` +} + +type AuditOptions struct { + Period string + ActivityThreshold int + OutputFile string + ExceptionsFile string + CheckOwners bool + CheckTeams bool +} + +type UserInfo struct { + Username string + Contributions int + Orgs []string + Teams map[string][]string + IsOwner bool +} + +type Exception struct { + Username string + Reason string +} + +func GetAllUsersInOrgs(o Options, orgs []string) (map[string]UserInfo, error) { + users := make(map[string]UserInfo) + config, err := LoadOrgs(o) + if err != nil { + return nil, err + } + + for _, org := range orgs { + for _, u := range append(config[org].Members, config[org].Admins...) { + if user, found := users[strings.ToLower(u)]; found { + if user.Orgs == nil { + user.Orgs = []string{} + } + user.Orgs = append(user.Orgs, org) + users[strings.ToLower(u)] = user + } else { + users[strings.ToLower(u)] = UserInfo{ + Username: u, + Orgs: []string{org}, + } + } + } + + for teamName, team := range config[org].Teams { + for _, u := range append(team.Members, team.Maintainers...) { + if user, found := users[strings.ToLower(u)]; found { + if user.Teams == nil { + user.Teams = make(map[string][]string) + } + user.Teams[org] = append(user.Teams[org], teamName) + users[strings.ToLower(u)] = user + } else { + users[strings.ToLower(u)] = UserInfo{ + Username: u, + Teams: map[string][]string{org: {teamName}}, + } + } + } + } + } + + return users, nil +} + +func GetContributions(period string) (map[string]Contribution, error) { + postBody := DevStatsRequest{ + Queries: []Query{ + { + RefID: "A", + DatasourceID: 1, + RawSQL: fmt.Sprintf(`select + sub."Rank", + sub.name as name, + sub.value +from ( + select row_number() over (order by sum(value) desc) as "Rank", + split_part(name, '$$$', 1) as name, + sum(value) as value + from + shdev + where + series = 'hdev_contributionsallall' + and period = '%s' + group by + split_part(name, '$$$', 1) +) sub`, period), + Format: "table", + }, + }, + } + + requestBody, err := json.Marshal(postBody) + if err != nil { + return nil, err + } + + resp, err := http.Post("https://certmanager.devstats.cncf.io/api/ds/query", "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad error code from devstats: %d: %w", resp.StatusCode, err) + } + + var parsed map[string]map[string]map[string][]Frames + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &parsed) + if err != nil { + return nil, fmt.Errorf("unable to parse json from devstats: %w", err) + } + + ranks := parsed["results"]["A"]["frames"][0].Data.Items[0] + usernames := parsed["results"]["A"]["frames"][0].Data.Items[1] + contribCounts := parsed["results"]["A"]["frames"][0].Data.Items[2] + + contribs := make(map[string]Contribution) + for i := 0; i < len(ranks); i++ { + username := usernames[i].(string) + contribs[username] = Contribution{ + int(ranks[i].(float64)), + username, + int(contribCounts[i].(float64)), + []string{}, + } + } + return contribs, nil +} + +func ReadExceptions(filepath string) ([]Exception, error) { + var exceptions []Exception + + data, err := os.ReadFile(filepath) + if err != nil { + return exceptions, err + } + + r := csv.NewReader(bytes.NewBuffer(data)) + records, err := r.ReadAll() + if err != nil { + return exceptions, err + } + + for _, record := range records[1:] { + exceptions = append(exceptions, Exception{Username: record[0], Reason: record[1]}) + } + + return exceptions, nil +} + +func usernameNotInContributors(contribs map[string]Contribution, username string) bool { + _, found := contribs[strings.ToLower(username)] + + return !found +} + +func usernameBelowActivityThreshold(contribs map[string]Contribution, username string, activityThreshold int) bool { + if !usernameNotInContributors(contribs, username) { + return false + } + + if contribs[strings.ToLower(username)].ContribCount <= activityThreshold { + return true + } + + return false +} + +func usernameInExceptions(exceptionalUsers []string, username string) bool { + for _, exceptionalUser := range exceptionalUsers { + if exceptionalUser == username { + return true + } + } + + return false +} + +func OrgAudit(o Options) error { + fmt.Printf("Running analysis with a lookback period of %s and activity threshold of %d\n", o.Period, o.ActivityThreshold) + + var exceptionalUsers []string + if o.ExceptionsFile != "" { + fmt.Printf("reading exceptions from %s\n", o.ExceptionsFile) + exceptions, err := ReadExceptions(o.ExceptionsFile) + if err != nil { + return err + } + + // build indexable map for exceptions + for _, exception := range exceptions { + exceptionalUsers = append(exceptionalUsers, exception.Username) + } + + // Print exceptions to stdout + fmt.Println("Total Exceptions:", len(exceptions)) + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Username", "Reason"}) + for _, exception := range exceptions { + table.Append([]string{exception.Username, exception.Reason}) + } + table.Render() + } + + fmt.Println("fetching data from devstats") + contributions, err := GetContributions(o.Period) + if err != nil { + return err + } + + fmt.Println("total contributors:", len(contributions)) + + fmt.Println("fetching org members") + users, err := GetAllUsersInOrgs(o, validOrgs) + if err != nil { + return err + } + + fmt.Println("filtering org members") + var orgMembersBelowThresholdAfterException []UserInfo + for _, userInfo := range users { + if usernameInExceptions(exceptionalUsers, userInfo.Username) { + fmt.Printf("username %s in exceptions. skipping...\n", userInfo.Username) + continue + } + if usernameNotInContributors(contributions, userInfo.Username) || + usernameBelowActivityThreshold(contributions, userInfo.Username, o.ActivityThreshold) { + + userInfo.Contributions = contributions[userInfo.Username].ContribCount + orgMembersBelowThresholdAfterException = append(orgMembersBelowThresholdAfterException, userInfo) + + fmt.Println("user below threshold or not in devstats:", userInfo.Username, " contributions: ", contributions[userInfo.Username].ContribCount) + } + } + + // sort users for readability + sort.Slice(orgMembersBelowThresholdAfterException, func(i, j int) bool { + return orgMembersBelowThresholdAfterException[i].Username < orgMembersBelowThresholdAfterException[j].Username + }) + + // populate if member is an owner + if o.CheckOwners { + for i, member := range orgMembersBelowThresholdAfterException { + isOwner, err := IsOwner(member.Username) + if err != nil { + return err + } + fmt.Printf("checking if user %s is owner: %v\n", member.Username, isOwner) + member.IsOwner = isOwner + orgMembersBelowThresholdAfterException[i] = member + } + } + + fmt.Println("Total \"Org Members\":", len(users)) + fmt.Println("Total \"Org Members\" below threshold after exceptions:", len(orgMembersBelowThresholdAfterException)) + + f, err := os.Create(o.OutputFile) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + + w := bufio.NewWriter(f) + table := tablewriter.NewWriter(w) + table.SetAutoWrapText(false) + + headers := []string{"Username", "Orgs"} + if o.CheckTeams { + headers = append(headers, "Teams") + } + if o.CheckOwners { + headers = append(headers, "Owner", "Owners Link") + } + table.SetHeader(headers) + + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + + for _, v := range orgMembersBelowThresholdAfterException { + row := []string{ + v.Username, + strings.Join(v.Orgs, ", "), + } + + if o.CheckTeams { + teams := []string{} + for org, team := range v.Teams { + teams = append(teams, fmt.Sprintf("%s: %s", org, strings.Join(team, ", "))) + } + row = append(row, strings.Join(teams, "; ")) + } + + if o.CheckOwners { + if v.IsOwner { + row = append(row, "yes", fmt.Sprintf("https://go.k8s.io/owners/%s", v.Username)) + } else { + row = append(row, "no", "") + } + } + + table.Append(row) + } + table.Render() + + w.Flush() + + return nil +} diff --git a/cmd/korg/korg.go b/cmd/korg/korg.go new file mode 100644 index 0000000..63c44c4 --- /dev/null +++ b/cmd/korg/korg.go @@ -0,0 +1,225 @@ +// +skip_license_check +/* +Copyright 2022 The Kubernetes Authors. + +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 main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var ( + validOrgs = []string{ + "cert-manager", + } + + orgConfigPathFormat = "config/%s/org.yaml" + + addHelpText = ` +Adds users to GitHub orgs and/or teams + +Add user to specified orgs: + + korg add --org kubernetes --org kubernetes-sigs + korg add --org kubernetes,kubernetes-sigs + +Note: Adding to teams is currently unsupported. + ` + + removeHelpText = ` +Remove users from GitHub orgs + +Remove user to specified orgs: + + korg remove --org kubernetes --org kubernetes-sigs + +Note: Removing from teams is currently unsupported. + ` + + auditHelpText = "Audit GitHub org members" +) + +type Options struct { + // global options + Confirm bool + RepoRoot string + Orgs []string + Teams []string + + // audit options + AuditOptions +} + +func AddMemberToOrgs(username string, options Options) error { + if invalidOrgs := findInvalidOrgs(options.Orgs); len(invalidOrgs) > 0 { + return fmt.Errorf("specified invalid orgs: %s", strings.Join(invalidOrgs, ", ")) + } + + if !options.Confirm { + fmt.Println("!!! running in dry-run mode. pass --confirm to persist changes.") + } + + configsModified := []string{} + for _, org := range options.Orgs { + fmt.Printf("adding %s to %s org\n", username, org) + + relativeConfigPath := fmt.Sprintf(orgConfigPathFormat, org) + configPath := filepath.Join(options.RepoRoot, relativeConfigPath) + + config, err := readConfig(configPath) + if err != nil { + return fmt.Errorf("reading config: %s", err) + } + + if stringInSliceCaseAgnostic(config.Members, username) || stringInSliceCaseAgnostic(config.Admins, username) { + return fmt.Errorf("user %s already exists in org %s", username, org) + } + + newMembers := append(config.Members, username) + config.Members = newMembers + caseAgnosticSort(config.Members) + + if options.Confirm { + fmt.Printf("saving config for %s org\n", org) + if err := saveConfig(configPath, config); err != nil { + return fmt.Errorf("saving config: %s", err) + } + } + + configsModified = append(configsModified, relativeConfigPath) + } + + if options.Confirm { + fmt.Println("committing changes") + + message := fmt.Sprintf("add %s to %s", username, strings.Join(options.Orgs, ", ")) + if err := commitChanges(options.RepoRoot, configsModified, message); err != nil { + return fmt.Errorf("committing changes: %s", err) + } + } + return nil +} + +func main() { + rootCmd := &cobra.Command{ + Use: "korg", + Short: "Manage Kubernetes community owned GitHub organizations", + } + + o := Options{} + rootCmd.PersistentFlags().BoolVar(&o.Confirm, "confirm", false, "confirm the changes") + rootCmd.PersistentFlags().StringVar(&o.RepoRoot, "root", ".", "root of the cm/org repo") + rootCmd.PersistentFlags().StringSliceVar(&o.Orgs, "org", []string{}, "orgs to add the user to") + + addCmd := &cobra.Command{ + Use: "add", + Short: "Add members to org and/or teams", + Long: addHelpText, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return fmt.Errorf("add only adds one user at a time. specified %d", len(args)) + } + + if len(o.Orgs) == 0 { + return fmt.Errorf("please specify atleast one org to add the user to") + } + + if invalidOrgs := findInvalidOrgs(o.Orgs); len(invalidOrgs) > 0 { + return fmt.Errorf("specified invalid orgs: %s", strings.Join(invalidOrgs, ", ")) + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + user := args[0] + if len(o.Orgs) > 0 { + return AddMemberToOrgs(user, o) + } + + return nil + }, + } + + removeCmd := &cobra.Command{ + Use: "remove", + Short: "Remove members from org", + Long: removeHelpText, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + if invalidOrgs := findInvalidOrgs(o.Orgs); len(invalidOrgs) > 0 { + return fmt.Errorf("specified invalid orgs: %s", strings.Join(invalidOrgs, ", ")) + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + user := args[0] + if len(o.Orgs) > 0 { + return RemoveMemberFromOrgs(o, user) + } + + return nil + }, + } + + // korg remove flags + removeCmd.Flags().StringSliceVar(&o.Orgs, "org", []string{}, "orgs to remove the user from") + + auditCmd := &cobra.Command{ + Use: "audit", + Short: "Audit GitHub org members", + Long: auditHelpText, + PreRunE: func(cmd *cobra.Command, args []string) error { + if invalidOrgs := findInvalidOrgs(o.Orgs); len(invalidOrgs) > 0 { + return fmt.Errorf("specified invalid orgs: %s", strings.Join(invalidOrgs, ", ")) + } + + if o.ActivityThreshold < 0 { + return fmt.Errorf("activity threshold cannot be negative") + } + + // TODO: Check if exceptions file is of the right format, if defined + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return OrgAudit(o) + }, + } + + // korg audit flags + auditCmd.Flags().IntVar(&o.ActivityThreshold, "activity-threshold", 0, "minimum activity to be considered active. default: 0") + auditCmd.Flags().StringVar(&o.Period, "period", "y", "period to look back for activity. possible values are defined in https://github.com/cncf/devstats/blob/master/docs/periods.md. default: y (Year)") + auditCmd.Flags().StringVar(&o.OutputFile, "output-file", "", "parse owners files. default: none") + auditCmd.Flags().StringVar(&o.ExceptionsFile, "exceptions-file", "", "exceptions for removal. default: none") + auditCmd.Flags().BoolVar(&o.CheckOwners, "check-owners", false, "parse owners files. default: false") + auditCmd.Flags().BoolVar(&o.CheckTeams, "check-teams", false, "check which teams the user belongs to. default: false") + + // commands + rootCmd.AddCommand(addCmd) + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(auditCmd) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/korg/remove.go b/cmd/korg/remove.go new file mode 100644 index 0000000..789d862 --- /dev/null +++ b/cmd/korg/remove.go @@ -0,0 +1,83 @@ +// +skip_license_check +/* +Copyright 2024 The Kubernetes Authors. + +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 main + +import ( + "fmt" + "path/filepath" + "strings" +) + +func RemoveMemberFromOrgs(o Options, username string) error { + if invalidOrgs := findInvalidOrgs(o.Orgs); len(invalidOrgs) > 0 { + return fmt.Errorf("specified invalid orgs: %s", strings.Join(invalidOrgs, ", ")) + } + + if !o.Confirm { + fmt.Println("!!! running in dry-run mode. pass --confirm to persist changes.") + } + + configsModified := []string{} + for _, org := range o.Orgs { + fmt.Printf("removing %s from %s org\n", username, org) + + relativeConfigPath := fmt.Sprintf(orgConfigPathFormat, org) + configPath := filepath.Join(o.RepoRoot, relativeConfigPath) + + config, err := readConfig(configPath) + if err != nil { + return fmt.Errorf("reading config: %s", err) + } + + if stringInSliceCaseAgnostic(config.Admins, username) { + return fmt.Errorf("user %s is an admin for org %s", username, org) + } + + if !stringInSliceCaseAgnostic(config.Members, username) { + return fmt.Errorf("user %s doesn't exist in org %s", username, org) + } + + // remove user from the org + for i, member := range config.Members { + if strings.EqualFold(username, member) { + config.Members = append(config.Members[:i], config.Members[i+1:]...) + break + } + } + + if o.Confirm { + fmt.Printf("saving config for %s org\n", org) + if err := saveConfig(configPath, config); err != nil { + return fmt.Errorf("saving config: %s", err) + } + } + + configsModified = append(configsModified, relativeConfigPath) + fmt.Printf("config files modified: %s\n", strings.Join(configsModified, ", ")) + } + + if o.Confirm { + fmt.Println("committing changes") + + message := fmt.Sprintf("remove %s from %s", username, strings.Join(o.Orgs, ", ")) + if err := commitChanges(o.RepoRoot, configsModified, message); err != nil { + return fmt.Errorf("committing changes: %s", err) + } + } + return nil +} diff --git a/cmd/korg/utils.go b/cmd/korg/utils.go new file mode 100644 index 0000000..65ebe03 --- /dev/null +++ b/cmd/korg/utils.go @@ -0,0 +1,217 @@ +// +skip_license_check +/* +Copyright 2022 The Kubernetes Authors. + +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 main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/sirupsen/logrus" + "sigs.k8s.io/prow/pkg/config/org" + "sigs.k8s.io/yaml" + + "github.com/hound-search/hound/client" +) + +func stringInSlice(slice []string, key string) bool { + for _, e := range slice { + if key == e { + return true + } + } + + return false +} + +// Note for the future: once we bump to the latest go version, we can replace this with helpers from stdlib slice package +func stringInSliceCaseAgnostic(slice []string, key string) bool { + for _, e := range slice { + if strings.EqualFold(key, e) { + return true + } + } + + return false +} + +func findInvalidOrgs(orgs []string) []string { + invalid := []string{} + + for _, org := range orgs { + if !stringInSlice(validOrgs, org) { + invalid = append(invalid, org) + } + } + + return invalid +} + +func readConfig(path string) (*org.Config, error) { + contents, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read file at %s: %s", path, err) + } + + config := org.Config{} + if err := yaml.Unmarshal(contents, &config); err != nil { + return nil, fmt.Errorf("unable to unmarshal config from %s: %s", path, err) + } + + return &config, nil +} + +func saveConfig(path string, config *org.Config) error { + b, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("unable to marshal config: %s", err) + } + + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("unable to fetch info for %s: %s", path, err) + } + + if err := os.WriteFile(path, b, info.Mode()); err != nil { + return fmt.Errorf("unable to write to %s: %s", path, err) + } + return nil +} + +func commitChanges(repoRoot string, configsModified []string, message string) error { + r, err := git.PlainOpen(repoRoot) + if err != nil { + return fmt.Errorf("unable to open repository: %s", err) + } + + w, err := r.Worktree() + if err != nil { + return fmt.Errorf("unable to fetch worktree: %s", err) + } + + for _, configModified := range configsModified { + _, err := w.Add(configModified) + if err != nil { + return fmt.Errorf("unable to stage changes: %s", err) + } + } + + _, err = w.Commit(message, &git.CommitOptions{}) + if err != nil { + return fmt.Errorf("unable to commit changes: %s", err) + } + + return nil +} + +func caseAgnosticSort(arr []string) { + sort.Slice(arr, func(i, j int) bool { + return strings.ToLower(arr[i]) < strings.ToLower(arr[j]) + }) +} + +func IsOwner(username string) (bool, error) { + url := fmt.Sprintf("https://cs.k8s.io/api/v1/search?stats=fosho&repos=*&rng=:20&q=%s&i=fosho&files=OWNERS&excludeFiles=vendor/", username) + resp, err := http.Get(url) + if err != nil { + return false, err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + var r client.Response + + err = json.Unmarshal(body, &r) + if err != nil { + return false, err + } + + return r.Stats.FilesOpened > 0, nil +} + +func unmarshalFromFile(path string) (*org.Config, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read: %v", err) + } + + return unmarshal(buf) +} + +func unmarshal(buf []byte) (*org.Config, error) { + var cfg org.Config + if err := yaml.Unmarshal(buf, &cfg, yaml.DisallowUnknownFields); err != nil { + return nil, fmt.Errorf("unmarshal: %v", err) + } + return &cfg, nil +} + +func LoadOrgs(o Options) (map[string]org.Config, error) { + config := map[string]org.Config{} + for _, orgName := range o.Orgs { + path := fmt.Sprintf("%s/config/%s/org.yaml", o.RepoRoot, orgName) + + cfg, err := unmarshalFromFile(path) + if err != nil { + return nil, fmt.Errorf("error in %s: %v", path, err) + } + + if cfg.Teams == nil { + cfg.Teams = map[string]org.Team{} + } + prefix := filepath.Dir(path) + err = filepath.Walk(prefix, func(path string, info os.FileInfo, err error) error { + switch { + case path == prefix: + return nil // Skip base dir + case info.IsDir() && filepath.Dir(path) != prefix: + logrus.Infof("Skipping %s and its children", path) + return filepath.SkipDir // Skip prefix/foo/bar/ dirs + case !info.IsDir() && filepath.Dir(path) == prefix: + return nil // Ignore prefix/foo files + case filepath.Base(path) == "teams.yaml": + teamCfg, err := unmarshalFromFile(path) + if err != nil { + return fmt.Errorf("error in %s: %v", path, err) + } + + for name, team := range teamCfg.Teams { + cfg.Teams[name] = team + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("merge teams %s: %v", path, err) + } + + config[orgName] = *cfg + } + return config, nil +} diff --git a/cmd/merge/main.go b/cmd/merge/main.go new file mode 100644 index 0000000..dc67761 --- /dev/null +++ b/cmd/merge/main.go @@ -0,0 +1,160 @@ +// +skip_license_check +/* +Copyright 2018 The Kubernetes Authors. + +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 main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/prow/pkg/config/org" + + "github.com/sirupsen/logrus" + "sigs.k8s.io/yaml" +) + +func parseKeyValue(s string) (string, string) { + p := strings.SplitN(s, "=", 2) + if len(p) == 1 { + return p[0], "" + } + return p[0], p[1] +} + +type flagMap map[string]string + +func (fm flagMap) String() string { + var parts []string + for key, value := range fm { + if value == "" { + parts = append(parts, key) + continue + } + parts = append(parts, key+"="+value) + } + return strings.Join(parts, ",") +} + +func (fm flagMap) Set(s string) error { + k, v := parseKeyValue(s) + if _, present := fm[k]; present { + return fmt.Errorf("duplicate key: %s", k) + } + fm[k] = v + return nil +} + +type options struct { + orgs flagMap + mergeTeams bool + ignoreTeams bool +} + +func main() { + o := options{orgs: flagMap{}} + flag.Var(o.orgs, "org-part", "Each instance adds an org-name=org.yaml part") + flag.BoolVar(&o.mergeTeams, "merge-teams", false, "Merge team-name/team.yaml files in each org.yaml dir") + flag.BoolVar(&o.ignoreTeams, "ignore-teams", false, "Never configure teams") + flag.Parse() + + for _, a := range flag.Args() { + logrus.Print("Extra", a) + o.orgs.Set(a) + } + + if o.mergeTeams && o.ignoreTeams { + logrus.Fatal("--merge-teams xor --ignore-teams, not both") + } + + cfg, err := loadOrgs(o) + if err != nil { + logrus.Fatalf("Failed to load orgs: %v", err) + } + pc := org.FullConfig{ + Orgs: cfg, + } + out, err := yaml.Marshal(pc) + if err != nil { + logrus.Fatalf("Failed to marshal orgs: %v", err) + } + fmt.Println(string(out)) +} + +func unmarshalFromFile(path string) (*org.Config, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read: %v", err) + } + + return unmarshal(buf) +} + +func unmarshal(buf []byte) (*org.Config, error) { + var cfg org.Config + if err := yaml.Unmarshal(buf, &cfg, yaml.DisallowUnknownFields); err != nil { + return nil, fmt.Errorf("unmarshal: %v", err) + } + return &cfg, nil +} + +func loadOrgs(o options) (map[string]org.Config, error) { + config := map[string]org.Config{} + for name, path := range o.orgs { + cfg, err := unmarshalFromFile(path) + if err != nil { + return nil, fmt.Errorf("error in %s: %v", path, err) + } + switch { + case o.ignoreTeams: + cfg.Teams = nil + case o.mergeTeams: + if cfg.Teams == nil { + cfg.Teams = map[string]org.Team{} + } + prefix := filepath.Dir(path) + err := filepath.Walk(prefix, func(path string, info os.FileInfo, err error) error { + switch { + case path == prefix: + return nil // Skip base dir + case info.IsDir() && filepath.Dir(path) != prefix: + logrus.Infof("Skipping %s and its children", path) + return filepath.SkipDir // Skip prefix/foo/bar/ dirs + case !info.IsDir() && filepath.Dir(path) == prefix: + return nil // Ignore prefix/foo files + case filepath.Base(path) == "teams.yaml": + teamCfg, err := unmarshalFromFile(path) + if err != nil { + return fmt.Errorf("error in %s: %v", path, err) + } + + for name, team := range teamCfg.Teams { + cfg.Teams[name] = team + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("merge teams %s: %v", path, err) + } + } + config[name] = *cfg + } + return config, nil +} diff --git a/cmd/merge/main_test.go b/cmd/merge/main_test.go new file mode 100644 index 0000000..12a4d93 --- /dev/null +++ b/cmd/merge/main_test.go @@ -0,0 +1,78 @@ +// +skip_license_check +/* +Copyright 2023 The Kubernetes Authors. + +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 main + +import ( + "bytes" + "fmt" + "testing" +) + +const testOrgConfig = `admins: +- admin1 +- admin2 +billing_email: github@kubernetes.io +default_repository_permission: read +description: Org desc +has_organization_projects: true +has_repository_projects: true +members: +- member1 +- member2 +members_can_create_repositories: false +name: Org +teams: + team-abc: + description: team-abc desc + members: + - team-member1 + privacy: closed + %s: + abc: write +` + +func TestStrictUnmarshalling(t *testing.T) { + cases := []struct { + repoKey string + expectError bool + desc string + }{ + { + repoKey: "repos", + expectError: false, + desc: "with a valid field", + }, + { + repoKey: "somethingBizzare", + expectError: true, + desc: "with an invalid field", + }, + } + + for _, c := range cases { + _, err := unmarshal( + bytes.NewBufferString(fmt.Sprintf(testOrgConfig, c.repoKey)).Bytes(), + ) + if !c.expectError && err != nil { + t.Errorf("unexpected error for %s: %v", c.desc, err) + } + if c.expectError && err == nil { + t.Errorf("expected error for %s", c.desc) + } + } +} diff --git a/config/cert-manager/OWNERS b/config/cert-manager/OWNERS new file mode 100644 index 0000000..e7f63f8 --- /dev/null +++ b/config/cert-manager/OWNERS @@ -0,0 +1,18 @@ +approvers: +- munnerz +- joshvanl +- wallrj +- jakexks +- maelvls +- sgtcodfish +- inteon +- thatsmrtalbot +reviewers: +- munnerz +- joshvanl +- wallrj +- jakexks +- maelvls +- sgtcodfish +- inteon +- thatsmrtalbot diff --git a/config/cert-manager/org.yaml b/config/cert-manager/org.yaml new file mode 100644 index 0000000..b76c374 --- /dev/null +++ b/config/cert-manager/org.yaml @@ -0,0 +1,372 @@ +name: cert-manager +description: "" +email: "" +company: "" +location: "" +billing_email: cert-manager-maintainers@googlegroups.com + +has_organization_projects: true +has_repository_projects: true +default_repository_permission: read +members_can_create_repositories: false + +admins: +- caniszczyk +- cert-manager-bot +- inteon +- jakexks +- JoshVanL +- maelvls +- munnerz +- SgtCoDFish +- ThatsMrTalbot +- thelinuxfoundation +- wallrj + +members: +- amibhi +- anbaig +- ARichman555 +- aveega +- bmsiegel +- charlieegan3 +- davidnoyes +- dcamzn +- divyansh-gupta +- erikgb +- euank +- FlorianLiebhart +- Hamidhasan +- hawksight +- heckj +- hmphome +- irbekrm +- james-w +- jniebuhr +- jsoref +- kollabpr +- kragniz +- KyleBS +- meghanayendamuri +- meyskens +- mikebryant +- Nalum +- ndbhat +- shankara-n +- simplyzee +- SpectralHiss +- tanujd11 +- vdesjardins + +teams: + milestone-maintainers: + description: cert-manager milestone maintainers + maintainers: + - inteon + - jakexks + - JoshVanL + - maelvls + - munnerz + - SgtCoDFish + - ThatsMrTalbot + - wallrj + privacy: closed + + trust-manager-maintainers: + members: + - erikgb + privacy: closed + repos: + trust-manager: admin + + approver-policy-maintainers: + members: + - erikgb + privacy: closed + repos: + approver-policy: admin + + aws-privateca-issuer-admins: + members: + - dcamzn + - jniebuhr + privacy: closed + repos: + aws-privateca-issuer: admin + + aws-privateca-issuer-maintainers: + members: + - amibhi + - anbaig + - ARichman555 + - aveega + - bmsiegel + - divyansh-gupta + - Hamidhasan + - hmphome + - kollabpr + - KyleBS + - meghanayendamuri + - ndbhat + - shankara-n + privacy: closed + repos: + aws-privateca-issuer: maintain + +repos: + cert-manager: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Automatically provision and manage TLS certificates in Kubernetes + has_projects: true + homepage: https://cert-manager.io + approver-policy: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: approver-policy is a cert-manager approver that allows users to define policies that restrict what certificates can be requested. + has_projects: true + homepage: https://cert-manager.io/docs/policy/approval/approver-policy/ + cmctl: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: '`cmctl` or `kubectl cert-manager` is the command line utility that makes cert-manager''ing easier.' + has_projects: true + has_wiki: false + homepage: https://cert-manager.io/docs/reference/cmctl/ + csi-driver: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: A Kubernetes CSI plugin to automatically mount signed certificates to Pods using ephemeral volumes + has_projects: true + homepage: https://cert-manager.io/docs/usage/csi-driver/ + csi-driver-spiffe: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: A Kubernetes CSI plugin to automatically mount SPIFFE certificates to Pods using ephemeral volumes + has_projects: true + homepage: https://cert-manager.io/docs/usage/csi-driver-spiffe/ + csi-lib: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: A library for building CSI drivers that request certificates from cert-manager + has_projects: true + example-approver-policy-plugin: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Example approver policy plugin https://cert-manager.io/docs/projects/approver-policy/#plugins + has_projects: true + issuer-lib: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: issuer-lib is the Go library for building cert-manager issuers. + has_projects: false + has_wiki: false + istio-csr: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: istio-csr is an agent that allows for Istio workload and control plane components to be secured using cert-manager. + has_projects: true + homepage: https://cert-manager.io/docs/usage/istio-csr/ + openshift-routes: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: OpenShift Route support for cert-manager + has_projects: true + trust-manager: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: trust-manager is an operator for distributing trust bundles across a Kubernetes cluster. + has_projects: true + homepage: https://cert-manager.io/docs/projects/trust-manager/ + webhook-example: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: A cert-manager sample repository for creating an ACME DNS01 solver webhook + has_projects: true + webhook-lib: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: 'Experimental: a Golang library for creating conversion & admission webhooks' + has_projects: true + website: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Source code for the cert-manager.io website, including project documentation + has_projects: true + homepage: https://cert-manager.io + sample-external-issuer: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: A sample external Issuer for cert-manager + has_projects: true + + aws-privateca-issuer: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Addon for cert-manager that issues certificates using AWS ACM PCA. + has_projects: true + has_wiki: false + + base-images: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Apko build automation for building base images used by cert-manager projects. + has_projects: true + has_wiki: false + boilersuite: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Boilerplate checker entirely in Go + has_projects: true + cert-manager-olm: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Definitions for the cert-manager operator published via Red Hat's Operator Lifecycle Manager (OLM) + has_projects: true + helm-tool: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: helm-tool is an internal cert-manager utility (can be broken or removed) which generates Helm docs, schema files and performs linting. + has_projects: true + klone: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Clone git sub-directories in your project. + has_projects: true + has_wiki: false + makefile-modules: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Reusable Makefile modules that can be kloned into your project + has_projects: true + release: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Releasing tooling for the cert-manager project + has_projects: true + print-your-cert: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Get your certificate printed at the cert-manager booth at KubeCon EU 2024 in Paris! + has_projects: true + homepage: https://cert-manager.github.io/print-your-cert/ + + community: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: Contribution and Collaboration Guidelines for the cert-manager Project + has_projects: true + homepage: https://cert-manager.io + infrastructure: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: main + description: cert-manager infrastructure + has_projects: false + has_wiki: false + org: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Configuration for the cert-manager GitHub org + has_projects: true + testing: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Repository containing cert-manager testing infrastructure configuration + has_projects: true + testing-addons: + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Tooling to deploy cert-manager with external dependencies for local testing + has_projects: true + + signer-ca: + archived: true + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Experimental 'local CA' based signer for Kubernetes 1.18 CSR API + has_projects: false + has_wiki: false + signer-venafi: + archived: true + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Experimental Venafi based signer for Kubernetes 1.18 CSR API https://github.com/kubernetes/enhancements/blob/master/keps/sig-auth/20190607-certificates-api.md#signers + has_projects: true + crypto: + archived: true + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + default_branch: cherry_pick_altcert + description: 'temporary fork to add support for ACME alternative certificate chains ' + has_issues: false + has_projects: true + has_wiki: false + homepage: https://golang.org/x/crypto + goversion: + archived: true + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: Print version used to build Go executables + has_issues: false + has_projects: true + webhook-operator: + archived: true + allow_merge_commit: false + allow_rebase_merge: false + allow_squash_merge: false + description: 'WIP: An extremely experimental operator for managing TLS for Kubernetes webhooks' + has_projects: true diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..988b939 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,270 @@ +// +skip_license_check +/* +Copyright 2018 The Kubernetes Authors. + +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 config + +import ( + "fmt" + "os" + "sort" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/prow/pkg/config/org" + "sigs.k8s.io/prow/pkg/github" + + "github.com/ghodss/yaml" +) + +var cfg org.FullConfig + +func TestMain(m *testing.M) { + configPath := os.Getenv("MERGED_CONFIG") + if configPath == "" { + fmt.Println("MERGED_CONFIG env variable must be set") + os.Exit(1) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + fmt.Printf("cannot read generated config.yaml from %s: %v\n", configPath, err) + os.Exit(1) + } + + if err := yaml.Unmarshal(raw, &cfg); err != nil { + fmt.Printf("cannot unmarshal generated config.yaml from %s: %v\n", configPath, err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +type owners struct { + Reviewers []string `json:"reviewers,omitempty"` + Approvers []string `json:"approvers"` +} + +func readInto(path string, i interface{}) error { + buf, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read: %v", err) + } + if err := yaml.Unmarshal(buf, i); err != nil { + return fmt.Errorf("unmarshal: %v", err) + } + return nil +} + +func loadOwners(dir string) (*owners, error) { + var own owners + if err := readInto(dir+"/OWNERS", &own); err != nil { + return nil, err + } + return &own, nil +} + +func loadOrg(dir string) (*org.Config, error) { + var cfg org.Config + if err := readInto(dir+"/org.yaml", &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func testDuplicates(list []string) error { + found := sets.Set[string]{} + dups := sets.Set[string]{} + for _, i := range list { + if found.Has(i) { + dups.Insert(i) + } + found.Insert(i) + } + if n := len(dups); n > 0 { + return fmt.Errorf("%d duplicate names: %s", n, strings.Join(dups.UnsortedList(), ", ")) + } + return nil +} + +func isSorted(list []string) bool { + items := make([]string, len(list)) + for _, l := range list { + items = append(items, strings.ToLower(l)) + } + + return sort.StringsAreSorted(items) +} + +func normalize(s []string) []string { + var out []string + for _, v := range s { + out = append(out, github.NormLogin(v)) + } + return out +} + +// testTeamMembers ensures that a user is not a maintainer and member at the same time, +// there are no duplicate names in the list and all users are org members. +func testTeamMembers(teams map[string]org.Team, admins sets.Set[string], orgMembers sets.Set[string], orgName string) []error { + var errs []error + for teamName, team := range teams { + teamMaintainers := sets.New(normalize(team.Maintainers)...) + teamMembers := sets.New(normalize(team.Members)...) + + // ensure all teams have privacy as closed + if team.Privacy == nil || (team.Privacy != nil && *team.Privacy != org.Closed) { + errs = append(errs, fmt.Errorf("The team %s in org %s doesn't have the `privacy: closed` field", teamName, orgName)) + } + + // check for non-admins in maintainers list + if nonAdminMaintainers := teamMaintainers.Difference(admins); len(nonAdminMaintainers) > 0 { + errs = append(errs, fmt.Errorf("The team %s in org %s has non-admins listed as maintainers; these users should be in the members list instead: %s", teamName, orgName, strings.Join(nonAdminMaintainers.UnsortedList(), ","))) + } + + // check for users in both maintainers and members + if both := teamMaintainers.Intersection(teamMembers); len(both) > 0 { + errs = append(errs, fmt.Errorf("The team %s in org %s has users in both maintainer admin and member roles: %s", teamName, orgName, strings.Join(both.UnsortedList(), ", "))) + } + + // check for duplicates + if err := testDuplicates(normalize(team.Maintainers)); err != nil { + errs = append(errs, fmt.Errorf("The team %s in org %s has duplicate maintainers: %v", teamName, orgName, err)) + } + if err := testDuplicates(normalize(team.Members)); err != nil { + errs = append(errs, fmt.Errorf("The team %s in org %s has duplicate members: %v", teamMembers, orgName, err)) + } + + // check if all are org members + if missing := teamMembers.Difference(orgMembers); len(missing) > 0 { + errs = append(errs, fmt.Errorf("The following members of team %s are not %s org members: %s", teamName, orgName, strings.Join(missing.UnsortedList(), ", "))) + } + + // check if admins are a regular member of team + if adminTeamMembers := teamMembers.Intersection(admins); len(adminTeamMembers) > 0 { + errs = append(errs, fmt.Errorf("The team %s in org %s has org admins listed as members; these users should be in the maintainers list instead, and cannot be on the members list: %s", teamName, orgName, strings.Join(adminTeamMembers.UnsortedList(), ", "))) + } + + // check if lists are sorted + if !isSorted(team.Maintainers) { + errs = append(errs, fmt.Errorf("The team %s in org %s has an unsorted list of maintainers", teamName, orgName)) + } + if !isSorted(team.Members) { + errs = append(errs, fmt.Errorf("The team %s in org %s has an unsorted list of members", teamName, orgName)) + } + + if team.Children != nil { + errs = append(errs, testTeamMembers(team.Children, admins, orgMembers, orgName)...) + } + } + return errs +} + +func testOrg(targetDir string, t *testing.T) { + cfg, err := loadOrg(targetDir) + if err != nil { + t.Fatalf("failed to load org.yaml: %v", err) + } + own, err := loadOwners(targetDir) + if err != nil { + t.Fatalf("failed to load OWNERS: %v", err) + } + + members := sets.New(normalize(cfg.Members)...) + admins := sets.New(normalize(cfg.Admins)...) + allOrgMembers := members.Union(admins) + + reviewers := sets.New(normalize(own.Reviewers)...) + approvers := sets.New(normalize(own.Approvers)...) + + if n := len(approvers); n < 5 { + t.Errorf("Require at least 5 approvers, found %d: %s", n, strings.Join(approvers.UnsortedList(), ", ")) + } + + if missing := reviewers.Difference(allOrgMembers); len(missing) > 0 { + t.Errorf("The following reviewers must be members: %s", strings.Join(missing.UnsortedList(), ", ")) + } + if missing := approvers.Difference(allOrgMembers); len(missing) > 0 { + t.Errorf("The following approvers must be members: %s", strings.Join(missing.UnsortedList(), ", ")) + } + if err := testDuplicates(normalize(own.Reviewers)); err != nil { + t.Errorf("duplicate reviewers: %v", err) + } + if err := testDuplicates(normalize(own.Approvers)); err != nil { + t.Errorf("duplicate approvers: %v", err) + } +} + +func TestAllOrgs(t *testing.T) { + f, err := os.Open(".") + if err != nil { + t.Fatalf("cannot read config: %v", err) + } + infos, err := f.Readdir(0) + if err != nil { + t.Fatalf("cannot read subdirs: %v", err) + } + for _, i := range infos { + if !i.IsDir() { + continue + } + n := i.Name() + if strings.HasPrefix(n, "linux_") || strings.HasPrefix(n, "darwin_") { + continue + } + t.Run(n, func(t *testing.T) { + if _, ok := cfg.Orgs[n]; !ok { + t.Errorf("%s missing from generated config.yaml", n) + } + testOrg(n, t) + }) + } + + for _, org := range cfg.Orgs { + members := sets.New(normalize(org.Members)...) + admins := sets.New(normalize(org.Admins)...) + allOrgMembers := members.Union(admins) + + if both := admins.Intersection(members); len(both) > 0 { + t.Errorf("users in both org admin and member roles for org '%s': %s", *org.Name, strings.Join(both.UnsortedList(), ", ")) + } + + if !admins.Has("cert-manager-bot") { + t.Errorf("cert-manager-bot must be an admin") + } + + if err := testDuplicates(normalize(org.Admins)); err != nil { + t.Errorf("duplicate admins: %v", err) + } + if err := testDuplicates(normalize(org.Members)); err != nil { + t.Errorf("duplicate members: %v", err) + } + if !isSorted(org.Admins) { + t.Errorf("admins for %s org are unsorted", *org.Name) + } + if !isSorted(org.Members) { + t.Errorf("members for %s org are unsorted", *org.Name) + } + + if errs := testTeamMembers(org.Teams, admins, allOrgMembers, *org.Name); errs != nil { + for _, err := range errs { + t.Error(err) + } + } + + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b73660 --- /dev/null +++ b/go.mod @@ -0,0 +1,77 @@ +module org + +go 1.22.0 + +toolchain go1.22.1 + +require ( + github.com/ghodss/yaml v1.0.0 + github.com/go-git/go-git/v5 v5.12.0 + github.com/hound-search/hound v0.7.1 + github.com/olekukonko/tablewriter v0.0.5 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.0 + k8s.io/apimachinery v0.30.1 + sigs.k8s.io/prow v0.0.0-20240517154251-ea10bd8144c3 + sigs.k8s.io/yaml v1.4.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cjwagner/httpcache v0.0.0-20230907212505-d4841bbad466 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/gomodule/redigo v1.8.5 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shurcooL/githubv4 v0.0.0-20210725200734-83ba7b4c9228 // indirect + github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect + github.com/skeema/knownhosts v1.2.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/mod v0.15.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.18.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af3b764 --- /dev/null +++ b/go.sum @@ -0,0 +1,268 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cjwagner/httpcache v0.0.0-20230907212505-d4841bbad466 h1:eUjwn08FDjbj8vBM31026tjBraJCu+qpDvo/q0EAvQk= +github.com/cjwagner/httpcache v0.0.0-20230907212505-d4841bbad466/go.mod h1:f7xZ2fRr8CqTp834KCxLW2pOXC/raqwhTbEvtxu/lRo= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= +github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/gomodule/redigo v1.8.5 h1:nRAxCa+SVsyjSBrtZmG/cqb6VbTmuRzpg/PoTFlpumc= +github.com/gomodule/redigo v1.8.5/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea h1:VcIYpAGBae3Z6BVncE0OnTE/ZjlDXqtYhOZky88neLM= +github.com/google/gofuzz v1.2.1-0.20210504230335-f78f29fc09ea/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hound-search/hound v0.7.1 h1:KF964rEC1GGk5r+HYAfwHByCjLk0pGzWnAIAPYK+Pro= +github.com/hound-search/hound v0.7.1/go.mod h1:b6SCdM+Ql0z6rotdr5PPB+CcJhZwhDbiGY1Nr8yQBc0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE= +github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shurcooL/githubv4 v0.0.0-20210725200734-83ba7b4c9228 h1:N5B+JgvM/DVYIxreItPJMM3yWrNO/GB2q4nESrtBisM= +github.com/shurcooL/githubv4 v0.0.0-20210725200734-83ba7b4c9228/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= +github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= +github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/prow v0.0.0-20240517154251-ea10bd8144c3 h1:nb1h77AHk9Av/b8QfrI/G2sBbPiEce6cTMz+vjWIvZI= +sigs.k8s.io/prow v0.0.0-20240517154251-ea10bd8144c3/go.mod h1:/1dTk8PU8p4eaYlmTwT0D5vbYJO4nt97YCGBqA749kk= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/hack/add-members.sh b/hack/add-members.sh new file mode 100755 index 0000000..24054e4 --- /dev/null +++ b/hack/add-members.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +skip_license_check + +# Copyright 2021 The Kubernetes Authors. +# +# 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. + +set -euo pipefail + +SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/.. + +DRY_RUN=${DRY_RUN:-false} + +if [[ -z ${WHO:-} ]]; then + echo "No users specified. Specify the users you would like to add with WHO=user1,user2" + exit 1 +fi + +[ -z ${REPOS+x} ] && echo "No repos specified. Defaulting to cert-manager." +REPOS=${REPOS:-"cert-manager"} + +cd "$SCRIPT_ROOT" +for username in ${WHO//,/ } +do + echo "Adding $username to $REPOS" + if [ "$DRY_RUN" = true ]; then + echo "Running in dry run mode." + go run ./cmd/korg add "$username" --org "$REPOS" + else + go run ./cmd/korg add "$username" --org "$REPOS" --confirm + fi +done diff --git a/hack/default-branches-report.sh b/hack/default-branches-report.sh new file mode 100755 index 0000000..d100b97 --- /dev/null +++ b/hack/default-branches-report.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +skip_license_check + +# Copyright 2021 The Kubernetes Authors. +# +# 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. + +readonly kubernetes_orgs=( + cert-manager +) + +readonly gh_api_cmd=( + gh api + --field=per_page=100 + --paginate + --method=GET +) + +function ensure_dependencies() { + if ! command -v gh >/dev/null; then + >&2 echo "gh not found. Please install: https://cli.github.com/manual/installation" + exit 1 + fi +} + +function main() { + local orgs=("$@") + ensure_dependencies + for org in "${orgs[@]}"; do + echo "* ${org}" + "${gh_api_cmd[@]}" "/orgs/${org}/repos" \ + --field=sort=full_name \ + --template \ + '{{range .}} * [{{if eq .default_branch "master"}} {{else}}X{{end}}] [{{.full_name}}]({{.html_url}}) {{"\n"}}{{end}}' + done + echo + echo "Manual inspection required if you want to link issues that tracked default branch name migration" +} + +args=("${@:1}") +if [ ${#args[@]} -eq 0 ]; then + args=("${kubernetes_orgs[@]}") +fi + +main "${args[@]}" diff --git a/hack/remove-members.sh b/hack/remove-members.sh new file mode 100755 index 0000000..5c79ad8 --- /dev/null +++ b/hack/remove-members.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +skip_license_check + +# Copyright 2021 The Kubernetes Authors. +# +# 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. + + +# This script removes members from all Kubernetes orgs by removing any +# occurrence of their GitHub handle from files contained within the /config +# directory. To preserve a clean git history, each removal is done as its own +# commit, mirroring the process done when a member is added. +# +# The script expects a file containing a list of GitHub handles, one per line. +# +# The environment variable `DRYRUN` controls whether the changes are simulated +# or live. The default, `false` will print the user, files modified, lines +# changed, and git commit command. +# +# ENV: +# DRYRUN: {true,false} - default: false +# ARGS: +# $1: path to a file containing a list of members to be removed +# USAGE: +# DRYRUN={true,false} ./remove-members.sh +# EXAMPLES: +# DRYRUN=false ./remove-members.sh inactive-members.txt # Prints changes +# DRYRUN=true ./remove-members.sh inactive-members.txt # Removes members + + +set -o errexit +set -o nounset +set -o pipefail + +readonly REPO_ROOT="$(git rev-parse --show-toplevel)" +readonly CONFIG_PATH="$REPO_ROOT/config" +readonly DRYRUN="${DRYRUN:-true}" + +if [ ! -f "$1" ]; then + echo "No file specified." + exit 1 +fi + +members=() +mapfile -t members < "$1" +for member in "${members[@]}"; do + matches=() + orgs=() + teams=() + + # Assembles list of files containing member to be removed (\s+)?- ${member}(\s+|\s+?#.*)?$ + mapfile -t matches < <(grep -rliP --include="*.yaml" "(\s+)?- $member(\s+|\s+?#.*)?$" "$CONFIG_PATH") + + for filename in "${matches[@]}"; do + + if [ "$(basename "$filename")" == "org.yaml" ]; then + if [ "$DRYRUN" == "false" ]; then + sed -E -i "/(\s+)?- $member(\s+|\s+?#.*)?$/Id" "$filename" + git add "$filename" + else + grep -inHP "(\s+)?- $member(\s+|\s+?#.*)?$" "$filename" + fi + # Adds org component to array to build removal commit message + orgs+=("$(basename "$(dirname "$filename")")") + fi + + if [ "$(basename "$filename")" == "teams.yaml" ]; then + if [ "$DRYRUN" == "false" ]; then + sed -E -i "/(\s+)?- $member(\s+|\s+?#.*)?$/Id" "$filename" + git add "$filename" + else + grep -inHP "(\s+)?- $member(\s+|\s+?#.*)?$" "$filename" + fi + # Adds team component to array to build removal commit message + # It is not perfect as teams can be in org files, but it does make + # commit messages more descriptive when possible. + teams+=("$(basename "$(dirname "$filename")")") + fi + done + + sorted_unique_orgs=() + sorted_unique_teams=() + + # Removes duplicates and sorts to build a better commit message. + mapfile -t sorted_unique_orgs < <(echo "${orgs[@]}" | tr ' ' '\n' | sort -u) + mapfile -t sorted_unique_teams < <(echo "${teams[@]}" | tr ' ' '\n' | sort -u) + + + + org_commit_msg="Remove $member from the " + if [[ "${#sorted_unique_orgs[@]}" -eq "1" ]]; then + org_commit_msg+="${sorted_unique_orgs[*]} org" + elif [[ "${#sorted_unique_orgs[@]}" -ge "1" ]]; then + printf -v joined '%s, ' "${sorted_unique_orgs[@]}" + org_commit_msg+="${joined%, } orgs" + fi + + cmd="git commit -m \"$org_commit_msg\"" + if [[ "${sorted_unique_teams[0]}" != "" ]]; then + for team in "${sorted_unique_teams[@]}"; do + cmd+=" -m \"Remove $member from $team teams\"" + done + fi + + if [ "$DRYRUN" == "false" ]; then + eval "$cmd" + else + echo "Command: $cmd" + fi +done