Skip to content

Instantly share code, notes, and snippets.

@dnwe
Created March 4, 2020 15:50
Show Gist options
  • Save dnwe/55ff3649a45ebdbcd5c67bdadebdc6ea to your computer and use it in GitHub Desktop.
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
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