Created
March 4, 2020 15:50
-
-
Save dnwe/55ff3649a45ebdbcd5c67bdadebdc6ea to your computer and use it in GitHub Desktop.
Enforces the correct team-ownership, protected branches, status checks etc. permissions across an org/user
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"context" | |
"fmt" | |
"log" | |
"net/http" | |
"net/url" | |
"os" | |
"reflect" | |
"sort" | |
"strings" | |
"golang.org/x/oauth2" | |
"github.com/google/go-github/v28/github" | |
) | |
const ( | |
// githubEndpoint allows you to override the api endpoint, | |
// e.g. for GitHub Enterprise: https://github.example.com/api/v3/ | |
githubEndpoint = "https://api.github.com" | |
// orgName allows you to override the org or user that you wish to | |
// apply enforcements to | |
orgName = "some-org-or-user-name" | |
// teamName allows you to specify the teamname (within your org) that you wish | |
// to always give write access to | |
teamName = "Development Team" | |
// webookURL specifies a push url to add to each repo as a webhook to be | |
// notified whenever a push occurs | |
webhookURL = "https://example.com/github-webhook/" | |
) | |
func strPtr(s string) *string { return &s } | |
// set of labels to add to each repo | |
var desiredLabels = []github.Label{ | |
{ | |
Name: strPtr("stale"), Color: strPtr("996633"), // Muddy Brown | |
}, | |
{ | |
Name: strPtr("DO NOT MERGE"), Color: strPtr("b10000"), // Red | |
}, | |
} | |
// Repos is a sortable (by last pushed time) list of github repositories | |
type Repos []*github.Repository | |
func (r Repos) Len() int { | |
return len(r) | |
} | |
func (r Repos) Swap(i, j int) { | |
r[i], r[j] = r[j], r[i] | |
} | |
func (r Repos) Less(i, j int) bool { | |
return r[i].GetPushedAt().Time.Before(r[j].GetPushedAt().Time) | |
} | |
func lookupTeam(ctx context.Context, client *github.Client) int64 { | |
teams, _, err := client.Teams.ListTeams(ctx, orgName, nil) | |
if err != nil { | |
log.Fatal(err) | |
} | |
for _, team := range teams { | |
if team.GetName() == teamName { | |
log.Printf("Team (%d)\n", team.GetID()) | |
return team.GetID() | |
} | |
} | |
log.Fatal("Cannot find Team") | |
return -1 | |
} | |
// filterRepos uses the given func to remove any repos from the given slice | |
// who return true when the func is applied to their name. This gives an | |
// opportunity to remove a set of repos from the enforcements | |
func filterRepos(repos Repos, fn func(string) bool) Repos { | |
filtered := repos[:0] | |
for _, x := range repos { | |
if fn(x.GetName()) { | |
continue | |
} | |
filtered = append(filtered, x) | |
} | |
return filtered | |
} | |
func lookupRepos(ctx context.Context, client *github.Client) Repos { | |
opt := &github.RepositoryListByOrgOptions{ | |
ListOptions: github.ListOptions{PerPage: 100}, | |
} | |
var allRepos Repos | |
for { | |
repos, resp, err := client.Repositories.ListByOrg(ctx, orgName, opt) | |
if err != nil { | |
log.Fatal(err) | |
} | |
allRepos = append(allRepos, repos...) | |
if resp.NextPage == 0 { | |
break | |
} | |
opt.Page = resp.NextPage | |
} | |
sort.Sort(sort.Reverse(allRepos)) | |
return allRepos | |
} | |
// fixPermissions removes any unexpected "collaborators" and ensures the main | |
// dev team has write access. The assumption is that members of the org will | |
// only receive read access by default | |
func fixPermissions(ctx context.Context, client *github.Client, repo *github.Repository, teamID int64) { | |
log.Printf("%s (%s)\n", repo.GetName(), repo.GetPushedAt().String()) | |
opt := &github.TeamAddTeamRepoOptions{ | |
Permission: "push", | |
} | |
if result, _, err := client.Teams.IsTeamRepo(ctx, teamID, orgName, repo.GetName()); err == nil { | |
perms := result.GetPermissions() | |
if !reflect.DeepEqual(perms, map[string]bool{"admin": false, "push": true, "pull": true}) { | |
log.Printf("Fixing team permissions on %s", repo.GetName()) | |
_, _ = client.Teams.AddTeamRepo(ctx, teamID, orgName, repo.GetName(), opt) | |
} | |
} else { | |
_, _ = client.Teams.AddTeamRepo(ctx, teamID, orgName, repo.GetName(), opt) | |
} | |
collabs, _, err := client.Repositories.ListCollaborators( | |
ctx, orgName, repo.GetName(), &github.ListCollaboratorsOptions{ | |
Affiliation: "direct", | |
}) | |
if err != nil { | |
log.Fatal(err) | |
} | |
if len(collabs) > 0 { | |
for _, collab := range collabs { | |
if collab.GetPermissions()["admin"] { | |
log.Printf("Found %s with unexpected admin", collab.GetLogin()) | |
log.Printf("Removing %s", collab.GetLogin()) | |
if _, err := client.Repositories.RemoveCollaborator( | |
ctx, orgName, repo.GetName(), collab.GetLogin()); err != nil { | |
log.Fatal(err) | |
} | |
} | |
} | |
} | |
} | |
// protectMasterBranch enforces pull reviews and passing status checks | |
// (if a .travis.yml is present) | |
func protectMasterBranch(ctx context.Context, client *github.Client, repo *github.Repository) { | |
// test if .travis.yml available and enable strict completion if so | |
contexts := []string{} | |
tree, _, err := client.Git.GetTree(ctx, orgName, repo.GetName(), "master", false) | |
if err == nil { | |
for _, v := range tree.Entries { | |
remotePath := v.GetPath() | |
if strings.HasSuffix(remotePath, ".travis.yml") { | |
contexts = append(contexts, "continuous-integration/travis-ci") | |
break | |
} | |
} | |
} | |
// enforce admins adhere to status checks and PRs _unless_ the compliance repo | |
shouldEnforceAdmins := repo.GetName() != "compliance" | |
preq := &github.ProtectionRequest{ | |
RequiredStatusChecks: &github.RequiredStatusChecks{ | |
Strict: true, | |
Contexts: contexts, | |
}, | |
RequiredPullRequestReviews: &github.PullRequestReviewsEnforcementRequest{ | |
RequiredApprovingReviewCount: 1, | |
}, | |
EnforceAdmins: shouldEnforceAdmins, | |
} | |
if _, _, err = client.Repositories.UpdateBranchProtection(ctx, orgName, repo.GetName(), "master", preq); err != nil { | |
log.Println(err) | |
} | |
} | |
func boolPtr(b bool) *bool { return &b } | |
// setupWebhook adds a push notification webhook URL to the given repo | |
func setupWebhook(ctx context.Context, client *github.Client, repo *github.Repository) { | |
hooks, _, err := client.Repositories.ListHooks( | |
ctx, orgName, repo.GetName(), &github.ListOptions{}) | |
if err != nil { | |
log.Fatal(err) | |
} | |
for _, hook := range hooks { | |
configURL := hook.Config["url"] | |
if configURL == webhookURL { | |
// already exists | |
return | |
} | |
} | |
hook, _, err := client.Repositories.CreateHook( | |
ctx, orgName, repo.GetName(), &github.Hook{ | |
Active: boolPtr(true), | |
Events: []string{"push"}, | |
Config: map[string]interface{}{ | |
"url": webhookURL, | |
"content_type": "application/json", | |
}, | |
}) | |
if err != nil { | |
log.Fatal(err) | |
} | |
log.Printf("Created webhook %s on %s\n", hook.GetURL(), repo.GetName()) | |
} | |
// makePrivate ensures the given repo is private-only | |
func makePrivate(ctx context.Context, client *github.Client, repo *github.Repository) { | |
if repo.GetPrivate() { | |
return | |
} | |
if repo.GetFork() { | |
log.Printf("Unable to make forked repo %s private", repo.GetName()) | |
return | |
} | |
log.Printf("Making repo %s private", repo.GetName()) | |
p := true | |
repo.Private = &p | |
_, _, err := client.Repositories.Edit(ctx, orgName, repo.GetName(), repo) | |
if err != nil { | |
log.Fatal(err) | |
} | |
} | |
// setupLabels ensures the desiredLabels exist and are present on the given | |
// repo with the desired colour. | |
// | |
// Note: go-github doesn't currently implement the Labels API so this uses | |
// bare http client calls | |
func setupLabels(ctx context.Context, client *github.Client, repo *github.Repository) []string { | |
var labels []*github.Label | |
var req *http.Request | |
var err error | |
// Get all lables, only 30 returned per page by default | |
baseLabelsURL := repo.GetURL() + "/labels" | |
for page := 1; ; page++ { | |
url := fmt.Sprintf("%s?per_page=100&page=%d", baseLabelsURL, page) | |
req, err = client.NewRequest(http.MethodGet, url, nil) | |
if err != nil { | |
panic(err) | |
} | |
var pageOfLabels []*github.Label | |
_, err = client.Do(ctx, req, &pageOfLabels) | |
if err != nil { | |
panic(err) | |
} | |
if len(pageOfLabels) == 0 { | |
break | |
} | |
labels = append(labels, pageOfLabels...) | |
} | |
allLabels := []string{} | |
for _, label := range labels { | |
if label != nil && label.Name != nil { | |
allLabels = append(allLabels, *label.Name) | |
} | |
} | |
desiredLoop: | |
for _, desired := range desiredLabels { | |
matchName := false | |
for _, l := range labels { | |
if *l.Name == *desired.Name { | |
matchName = true | |
if *l.Color == *desired.Color { | |
continue desiredLoop | |
} | |
} | |
} | |
// name match only, the other desired attributes are different; remove and | |
// recreate label | |
if matchName { | |
log.Printf("Deleting label %s in repo %s", *desired.Name, repo.GetName()) | |
req, err = client.NewRequest( | |
http.MethodDelete, fmt.Sprintf("%s/%s", baseLabelsURL, url.QueryEscape(*desired.Name)), nil) | |
if err != nil { | |
panic(err) | |
} | |
_, err = client.Do(ctx, req, nil) | |
if err != nil { | |
panic(err) | |
} | |
} | |
log.Printf("Creating label %s in repo %s", *desired.Name, repo.GetName()) | |
req, err = client.NewRequest(http.MethodPost, baseLabelsURL, &desired) | |
if err != nil { | |
panic(err) | |
} | |
_, err = client.Do(ctx, req, nil) | |
if err != nil { | |
panic(err) | |
} | |
allLabels = append(allLabels, *desired.Name) | |
} | |
return allLabels | |
} | |
func main() { | |
ctx := context.Background() | |
ts := oauth2.StaticTokenSource( | |
&oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")}, | |
) | |
tc := oauth2.NewClient(ctx, ts) | |
client := github.NewClient(tc) | |
u, err := url.Parse(githubEndpoint) | |
if err != nil { | |
log.Fatal(err) | |
} | |
client.BaseURL = u | |
// lookup team/org and fetch repos | |
teamID := lookupTeam(ctx, client) | |
allRepos := filterRepos(lookupRepos(ctx, client), func(s string) bool { | |
// check the repo name against one or more matches who you want to _skip_ enforcements on | |
return s == "dotfiles" || s == "zzz" // etc. | |
}) | |
// Track all labels that exist across all the repos (to help with curating | |
// the desired default set from an existing population of labels) | |
labelMap := map[string]bool{} | |
// Loop over each non-archived, non-filtered repo and apply the various | |
// enforcements | |
for _, repo := range allRepos { | |
if *repo.Archived { | |
continue | |
} | |
fixPermissions(ctx, client, repo, teamID) | |
protectMasterBranch(ctx, client, repo) | |
setupWebhook(ctx, client, repo) | |
makePrivate(ctx, client, repo) | |
for _, label := range setupLabels(ctx, client, repo) { | |
labelMap[label] = true | |
} | |
} | |
// Print current labels that were found across all repos | |
fmt.Printf("%d organization labels:\n", len(labelMap)) | |
labels := []string{} | |
for label := range labelMap { | |
labels = append(labels, label) | |
} | |
sort.Strings(labels) | |
for _, label := range labels { | |
fmt.Printf("%v\n", label) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment