Repository: palantir/policy-bot
Pull request #294 (2021-05-05 00:36:38)

Add support for triage and maintain permissions
Deprecate the `admins` and `write_collaborators` properties and replace them with a new `permissions` key that accepts all permission levels. Like before, users must specify levels explicitly and there is no permission inheritance (e.g. users with admin permissions do not count as having write permissions.)

The GitHub REST API does not expose the new permissions, so switch collaborator lookup to GraphQL, which does include them. This will remove caching of collaborator information, so we may need to revisit that to reduce request rates.

I still need to fix up reviewer assignment to work with the new fields. That's coming in a future commit.

bluekeyes: Ok, this should be set for final review now. Local testing looks good and I think I found all the API scorpions 🦂. 

Patch:
diff --git a/README.md b/README.md
index 50b93bc..1353592 100644
--- a/README.md
+++ b/README.md
@@ -343,10 +343,20 @@ requires:
   organizations: ["org1", "org2"]
   teams: ["org1/team1", "org2/team2"]
 
-  # allows approval by admins of the org or repository
-  admins: true
-  # allows approval by users who have write on the repository
-  write_collaborators: true
+  # A user must have a permission in this list for their approval to count for
+  # this rule. Valid permissions are "admin", "maintain", "write", "triage",
+  # and "read".
+  permissions: ["admin", "maintain", "write"]
+
+  # Deprecated: use 'permissions: ["admin"]'
+  #
+  # Allows approval by admins of the org or repository
+  # admins: true
+
+  # Deprecated: use 'permissions: ["write"]'
+  #
+  # Allows approval by users who have write on the repository
+  # write_collaborators: true
 ```
 
 ### Approval Policies
@@ -524,6 +534,7 @@ when Pull Requests are opened by setting the `request_review` option.
 
 The `mode` enum modifies how reviewers are selected. There are currently three
 supported options:
+
  * `all-users` to request all users who can approve
  * `random-users` to randomly select the number of users that are required
  * `teams` to request teams for review. Teams must be repository collaborators
@@ -536,9 +547,16 @@ options:
     mode: all-users|random-users|teams
 ```
 
-The set of requested reviewers will not include the author of the Pull Request or
+The set of requested reviewers will not include the author of the pull request or
 users who are not collaborators on the repository.
 
+When requesting reviews for rules that use repository permissions to select
+approvers, only users who are either direct collaborators or members of
+repository teams are eligible for review selection. For example, if a rule can
+be approved by any user with `admin` permission, only direct or team admins
+will be selected for review. Users who inherit repository `admin` permissions
+as organization owners are not selected.
+
 #### Automatically Requesting Reviewers Example
 
 Given the following example requirement rule,
diff --git a/go.mod b/go.mod
index 30918d4..05132cc 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
 	github.com/c2h5oh/datasize v0.0.0-20171227191756-4eba002a5eae
 	github.com/die-net/lrucache v0.0.0-20181227122439-19a39ef22a11
 	github.com/google/go-github/v32 v32.0.0
+	github.com/google/go-querystring v1.0.0
 	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
 	github.com/palantir/go-baseapp v0.2.2
 	github.com/palantir/go-githubapp v0.5.0
diff --git a/policy/approval/approve.go b/policy/approval/approve.go
index 6ba9e6b..13ad9ee 100644
--- a/policy/approval/approve.go
+++ b/policy/approval/approve.go
@@ -133,26 +133,39 @@ func (r *Rule) Evaluate(ctx context.Context, prctx pull.Context) (res common.Res
 		res.Status = common.StatusApproved
 	} else {
 		res.Status = common.StatusPending
-
-		if r.Options.RequestReview.Enabled {
-			mode := r.Options.RequestReview.Mode
-			if mode == "" {
-				mode = common.RequestModeRandomUsers
-			}
-			res.ReviewRequestRule = &common.ReviewRequestRule{
-				Users:              r.Requires.Users,
-				Teams:              r.Requires.Teams,
-				Organizations:      r.Requires.Organizations,
-				Admins:             r.Requires.Admins,
-				WriteCollaborators: r.Requires.WriteCollaborators,
-				RequiredCount:      r.Requires.Count,
-				Mode:               mode,
-			}
-		}
+		res.ReviewRequestRule = r.getReviewRequestRule()
 	}
 	return
 }
 
+func (r *Rule) getReviewRequestRule() *common.ReviewRequestRule {
+	if !r.Options.RequestReview.Enabled {
+		return nil
+	}
+
+	mode := r.Options.RequestReview.Mode
+	if mode == "" {
+		mode = common.RequestModeRandomUsers
+	}
+
+	perms := append([]pull.Permission(nil), r.Requires.Permissions...)
+	if r.Requires.Admins {
+		perms = append(perms, pull.PermissionAdmin)
+	}
+	if r.Requires.WriteCollaborators {
+		perms = append(perms, pull.PermissionWrite)
+	}
+
+	return &common.ReviewRequestRule{
+		Users:         r.Requires.Users,
+		Teams:         r.Requires.Teams,
+		Organizations: r.Requires.Organizations,
+		Permissions:   perms,
+		RequiredCount: r.Requires.Count,
+		Mode:          mode,
+	}
+}
+
 func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context) (bool, string, error) {
 	log := zerolog.Ctx(ctx)
 
diff --git a/policy/common/actor.go b/policy/common/actor.go
index 81cba8b..347e61a 100644
--- a/policy/common/actor.go
+++ b/policy/common/actor.go
@@ -29,19 +29,23 @@ type Actors struct {
 	Teams         []string `yaml:"teams"`
 	Organizations []string `yaml:"organizations"`
 
-	// Github repository specific interpolation options
+	// Deprecated: use Permissions with "admin" or "write"
 	Admins             bool `yaml:"admins"`
 	WriteCollaborators bool `yaml:"write_collaborators"`
-}
 
-const (
-	GithubWritePermission = "write"
-	GithubAdminPermission = "admin"
-)
+	// A list of GitHub collaborator permissions that are allowed. Values may
+	// be any of "admin", "maintain", "write", "triage", and "read".
+	//
+	// Each desired permission must be listed explicity. For example, even
+	// though "admin" is a superset of "write" in GitHub, both "admin" and
+	// "write" must be included in the list to allow users with either role.
+	Permissions []pull.Permission
+}
 
 // IsEmpty returns true if no conditions for actors are defined.
 func (a *Actors) IsEmpty() bool {
-	return a == nil || (len(a.Users) == 0 && len(a.Teams) == 0 && len(a.Organizations) == 0 && !a.Admins && !a.WriteCollaborators)
+	return a == nil || (len(a.Users) == 0 && len(a.Teams) == 0 && len(a.Organizations) == 0 &&
+		len(a.Permissions) == 0 && !a.Admins && !a.WriteCollaborators)
 }
 
 // IsActor returns true if the given user satisfies at least one of the
@@ -73,25 +77,26 @@ func (a *Actors) IsActor(ctx context.Context, prctx pull.Context, user string) (
 		}
 	}
 
+	perms := append([]pull.Permission(nil), a.Permissions...)
 	if a.Admins {
-		isAdmin, err := prctx.IsCollaborator(prctx.RepositoryOwner(), prctx.RepositoryName(), user, GithubAdminPermission)
-		if err != nil {
-			return false, errors.Wrap(err, "failed to get admin collaborator status")
-		}
-		if isAdmin {
-			return true, nil
-		}
+		perms = append(perms, pull.PermissionAdmin)
 	}
-
 	if a.WriteCollaborators {
-		isWrite, err := prctx.IsCollaborator(prctx.RepositoryOwner(), prctx.RepositoryName(), user, GithubWritePermission)
-		if err != nil {
-			return false, errors.Wrap(err, "failed to get write collaborator status")
-		}
-		if isWrite {
+		perms = append(perms, pull.PermissionWrite)
+	}
+
+	userPerm, err := prctx.CollaboratorPermission(user)
+	if err != nil {
+		return false, err
+	}
+	if userPerm == pull.PermissionNone {
+		return false, nil
+	}
+
+	for _, p := range perms {
+		if userPerm == p {
 			return true, nil
 		}
 	}
-
 	return false, nil
 }
diff --git a/policy/common/result.go b/policy/common/result.go
index 71c6bc0..7071b94 100644
--- a/policy/common/result.go
+++ b/policy/common/result.go
@@ -14,6 +14,10 @@
 
 package common
 
+import (
+	"github.com/palantir/policy-bot/pull"
+)
+
 type EvaluationStatus int
 
 const (
@@ -46,12 +50,11 @@ const (
 )
 
 type ReviewRequestRule struct {
-	Teams              []string
-	Users              []string
-	Organizations      []string
-	Admins             bool
-	WriteCollaborators bool
-	RequiredCount      int
+	Teams         []string
+	Users         []string
+	Organizations []string
+	Permissions   []pull.Permission
+	RequiredCount int
 
 	Mode RequestMode
 }
diff --git a/policy/reviewer/reviewer.go b/policy/reviewer/reviewer.go
index eade3ef..8585378 100644
--- a/policy/reviewer/reviewer.go
+++ b/policy/reviewer/reviewer.go
@@ -19,7 +19,6 @@ import (
 	"fmt"
 	"math/rand"
 	"sort"
-	"strings"
 
 	"github.com/palantir/policy-bot/policy/common"
 	"github.com/palantir/policy-bot/pull"
@@ -149,163 +148,148 @@ func selectOrgMembers(prctx pull.Context, allOrgs []string) ([]string, error) {
 	return allOrgsMembers, nil
 }
 
-func selectAdminTeamMembers(prctx pull.Context) (map[string][]string, error) {
-	// Resolve all admin teams on the repo, and resolve their user membership
-	adminTeams := make(map[string][]string)
-	teams, err := prctx.Teams()
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get list of teams collaborators")
-	}
-
-	for team, perm := range teams {
-		if perm == common.GithubAdminPermission {
-			fullTeamName := fmt.Sprintf("%s/%s", prctx.RepositoryOwner(), team)
-			admins, err := prctx.TeamMembers(fullTeamName)
-			if err != nil {
-				return nil, errors.Wrapf(err, "Unable to get list of members for %s", team)
-			}
-			adminTeams[fullTeamName] = admins
-		}
-	}
-
-	return adminTeams, nil
-}
-
-func getPossibleReviewers(prctx pull.Context, users map[string]struct{}, collaboratorsToConsider map[string]string) []string {
+func getPossibleReviewers(prctx pull.Context, users map[string]struct{}, collaborators []*pull.Collaborator) []string {
 	var possibleReviewers []string
-	for u := range users {
-		// Remove the author and any users who aren't collaborators
-		// since github will fail to assign _anyone_ if the request contains one of these
-		_, ok := collaboratorsToConsider[u]
-		if u != prctx.Author() && ok {
-			possibleReviewers = append(possibleReviewers, u)
+	for _, c := range collaborators {
+		_, exists := users[c.Name]
+		if c.Name != prctx.Author() && exists {
+			possibleReviewers = append(possibleReviewers, c.Name)
 		}
 	}
 
-	// Because possibleReviewers is built from map iteration, its order changes
-	// each time. We need reviewer selection to be consistent when using a
-	// fixed random seed, so sort the reviewers before returning.
+	// We need reviewer selection to be consistent when using a fixed random
+	// seed, so sort the reviewers before returning.
 	sort.Strings(possibleReviewers)
 	return possibleReviewers
 }
 
-func stripOrg(teamWithOrg string) (string, error) {
-	split := strings.Split(teamWithOrg, "/")
-	if len(split) < 0 {
-		return "", fmt.Errorf("expected org with team: '%s'", teamWithOrg)
-	}
-	return split[1], nil
-}
-
 func SelectReviewers(ctx context.Context, prctx pull.Context, results []*common.Result, r *rand.Rand) (Selection, error) {
 	selection := Selection{}
 
 	for _, child := range results {
 		logger := zerolog.Ctx(ctx).With().Str(LogKeyLeafNode, child.Name).Logger()
+		childCtx := logger.WithContext(ctx)
 
-		allUsers := make(map[string]struct{})
-		allTeamsWithUsers := make(map[string][]string)
-
-		for _, user := range child.ReviewRequestRule.Users {
-			allUsers[user] = struct{}{}
-		}
-
-		if len(child.ReviewRequestRule.Teams) > 0 {
-			logger.Debug().Msg("Selecting from teams for review")
-			teamsToUsers, err := selectTeamMembers(prctx, child.ReviewRequestRule.Teams)
-			if err != nil {
-				logger.Warn().Err(err).Msgf("failed to get member listing for teams, skipping team member selection")
+		switch child.ReviewRequestRule.Mode {
+		case common.RequestModeTeams:
+			if err := selectTeamReviewers(childCtx, prctx, &selection, child); err != nil {
+				return selection, err
 			}
-			for team, users := range teamsToUsers {
-				allTeamsWithUsers[team] = users
-				for _, user := range users {
-					allUsers[user] = struct{}{}
-				}
+		case common.RequestModeAllUsers, common.RequestModeRandomUsers:
+			if err := selectUserReviewers(childCtx, prctx, &selection, child, r); err != nil {
+				return selection, err
 			}
+		default:
+			return selection, fmt.Errorf("unknown reviewer selection mode: %s", child.ReviewRequestRule.Mode)
 		}
+	}
+	return selection, nil
+}
 
-		if len(child.ReviewRequestRule.Organizations) > 0 {
-			logger.Debug().Msg("Selecting from organizations for review")
-			orgMembers, err := selectOrgMembers(prctx, child.ReviewRequestRule.Organizations)
-			if err != nil {
-				logger.Warn().Err(err).Msg("failed to get member listing for org, skipping org member selection")
-			}
-			for _, user := range orgMembers {
+func selectTeamReviewers(ctx context.Context, prctx pull.Context, selection *Selection, result *common.Result) error {
+	logger := zerolog.Ctx(ctx)
+
+	eligibleTeams, err := prctx.Teams()
+	if err != nil {
+		return err
+	}
+
+	var teams []string
+	for team, perm := range eligibleTeams {
+		switch {
+		case requestsTeam(result, prctx.RepositoryOwner()+"/"+team):
+			teams = append(teams, team)
+		case requestsPermission(result, perm):
+			teams = append(teams, team)
+		}
+	}
+
+	logger.Debug().Msgf("Requesting %d teams for review", len(teams))
+	selection.Teams = append(selection.Teams, teams...)
+	return nil
+}
+
+func selectUserReviewers(ctx context.Context, prctx pull.Context, selection *Selection, result *common.Result, r *rand.Rand) error {
+	logger := zerolog.Ctx(ctx)
+
+	allUsers := make(map[string]struct{})
+	for _, user := range result.ReviewRequestRule.Users {
+		allUsers[user] = struct{}{}
+	}
+
+	if len(result.ReviewRequestRule.Teams) > 0 {
+		logger.Debug().Msg("Selecting from teams for review")
+		teamsToUsers, err := selectTeamMembers(prctx, result.ReviewRequestRule.Teams)
+		if err != nil {
+			logger.Warn().Err(err).Msgf("failed to get member listing for teams, skipping team member selection")
+		}
+		for _, users := range teamsToUsers {
+			for _, user := range users {
 				allUsers[user] = struct{}{}
 			}
 		}
+	}
 
-		collaboratorsToConsider, err := prctx.RepositoryCollaborators()
+	if len(result.ReviewRequestRule.Organizations) > 0 {
+		logger.Debug().Msg("Selecting from organizations for review")
+		orgMembers, err := selectOrgMembers(prctx, result.ReviewRequestRule.Organizations)
 		if err != nil {
-			return selection, errors.Wrap(err, "failed to list repository collaborators")
+			logger.Warn().Err(err).Msg("failed to get member listing for org, skipping org member selection")
 		}
-
-		if child.ReviewRequestRule.WriteCollaborators {
-			logger.Debug().Msg("Selecting from write collaborators for review")
-			for user, perm := range collaboratorsToConsider {
-				if perm == common.GithubWritePermission {
-					allUsers[user] = struct{}{}
-				}
-			}
+		for _, user := range orgMembers {
+			allUsers[user] = struct{}{}
 		}
+	}
 
-		if child.ReviewRequestRule.Admins {
-			logger.Debug().Msg("Selecting from admins for review")
-			adminTeams, err := selectAdminTeamMembers(prctx)
-			if err != nil {
-				return selection, errors.Wrap(err, "failed to select admins")
-			}
-			for team, members := range adminTeams {
-				allTeamsWithUsers[team] = members
-				for _, admin := range members {
-					allUsers[admin] = struct{}{}
-				}
+	collaborators, err := prctx.RepositoryCollaborators()
+	if err != nil {
+		return errors.Wrap(err, "failed to list repository collaborators")
+	}
+
+	if len(result.ReviewRequestRule.Permissions) > 0 {
+		logger.Debug().Msg("Selecting from collaborators by permission for review")
+		for _, c := range collaborators {
+			if c.PermissionViaRepo && requestsPermission(result, c.Permission) {
+				allUsers[c.Name] = struct{}{}
 			}
 		}
+	}
 
-		switch child.ReviewRequestRule.Mode {
-		case common.RequestModeTeams:
-			permissionedTeams, err := prctx.Teams()
-			if err != nil {
-				return selection, err
-			}
+	possibleReviewers := getPossibleReviewers(prctx, allUsers, collaborators)
+	if len(possibleReviewers) == 0 {
+		logger.Debug().Msg("Found 0 eligible reviewers; skipping review request")
+		return nil
+	}
 
-			var possibleTeams []string
-			for team := range allTeamsWithUsers {
-				slug, err := stripOrg(team)
-				if err != nil {
-					return selection, err
-				}
-				if _, ok := permissionedTeams[slug]; ok {
-					possibleTeams = append(possibleTeams, slug)
-				}
-			}
-			logger.Debug().Msgf("Found %d total teams; requesting teams", len(possibleTeams))
-			selection.Teams = append(selection.Teams, possibleTeams...)
-
-		case common.RequestModeAllUsers:
-			possibleReviewers := getPossibleReviewers(prctx, allUsers, collaboratorsToConsider)
-			if len(possibleReviewers) > 0 {
-				logger.Debug().Msgf("Found %d total reviewers after removing author and non-collaborators; requesting all", len(possibleReviewers))
-				selection.Users = append(selection.Users, possibleReviewers...)
-			} else {
-				logger.Debug().Msg("Did not find candidates for review after removing author and non-collaborators")
-			}
+	switch result.ReviewRequestRule.Mode {
+	case common.RequestModeAllUsers:
+		logger.Debug().Msgf("Found %d eligible reviewers; selecting all", len(possibleReviewers))
+		selection.Users = append(selection.Users, possibleReviewers...)
 
-		case common.RequestModeRandomUsers:
-			possibleReviewers := getPossibleReviewers(prctx, allUsers, collaboratorsToConsider)
-			if len(possibleReviewers) > 0 {
-				logger.Debug().Msgf("Found %d total candidates for review after removing author and non-collaborators; randomly selecting %d", len(possibleReviewers), child.ReviewRequestRule.RequiredCount)
-				selectedUsers := selectRandomUsers(child.ReviewRequestRule.RequiredCount, possibleReviewers, r)
-				selection.Users = append(selection.Users, selectedUsers...)
-			} else {
-				logger.Debug().Msg("Did not find candidates for review after removing author and non-collaborators")
-			}
+	case common.RequestModeRandomUsers:
+		count := result.ReviewRequestRule.RequiredCount
+		selectedUsers := selectRandomUsers(count, possibleReviewers, r)
 
-		default:
-			return selection, fmt.Errorf("unknown mode '%s' supplied", child.ReviewRequestRule.Mode)
+		logger.Debug().Msgf("Found %d eligible reviewers; randomly selecting %d", len(possibleReviewers), count)
+		selection.Users = append(selection.Users, selectedUsers...)
+	}
+	return nil
+}
+
+func requestsTeam(r *common.Result, team string) bool {
+	for _, t := range r.ReviewRequestRule.Teams {
+		if t == team {
+			return true
 		}
 	}
+	return false
+}
 
-	return selection, nil
+func requestsPermission(r *common.Result, perm pull.Permission) bool {
+	for _, p := range r.ReviewRequestRule.Permissions {
+		if p == perm {
+			return true
+		}
+	}
+	return false
 }
diff --git a/pull/context.go b/pull/context.go
index cd991bc..77beada 100644
--- a/pull/context.go
+++ b/pull/context.go
@@ -28,9 +28,6 @@ type MembershipContext interface {
 	// IsOrgMember returns true if the user is a member of the given organzation.
 	IsOrgMember(org, user string) (bool, error)
 
-	// IsCollaborator returns true if the user meets the desiredPerm of the given organzation's repository.
-	IsCollaborator(org, repo, user, desiredPerm string) (bool, error)
-
 	// TeamMembers returns the list of usernames in the given organization's team.
 	TeamMembers(team string) ([]string, error)
 
@@ -98,13 +95,15 @@ type Context interface {
 	// IsDraft returns the draft status of the Pull Request.
 	IsDraft() bool
 
-	// RepositoryCollaborators lists the set of collaborators, along with
-	// their respective permission on a repo.
-	RepositoryCollaborators() (map[string]string, error)
+	// RepositoryCollaborators returns the repository collaborators.
+	RepositoryCollaborators() ([]*Collaborator, error)
+
+	// CollaboratorPermission returns the permission level of user on the repository.
+	CollaboratorPermission(user string) (Permission, error)
 
-	// Teams lists the set of team collaborators, along with
-	// their respective permission on a repo.
-	Teams() (map[string]string, error)
+	// Teams lists the set of team collaborators, along with their respective
+	// permission on a repo.
+	Teams() (map[string]Permission, error)
 
 	// RequestedReviewers returns any current and dismissed review requests on
 	// the pull request.
@@ -166,6 +165,21 @@ func (c *Commit) Users() []string {
 	return users
 }
 
+type SignatureType string
+
+const (
+	SignatureGpg   SignatureType = "GpgSignature"
+	SignatureSmime SignatureType = "SmimeSignature"
+)
+
+type Signature struct {
+	Type    SignatureType
+	IsValid bool
+	KeyID   string
+	Signer  string
+	State   string
+}
+
 type Comment struct {
 	CreatedAt time.Time
 	Author    string
@@ -205,10 +219,11 @@ type Reviewer struct {
 	Removed bool
 }
 
-type Signature struct {
-	Type    SignatureType
-	IsValid bool
-	KeyID   string
-	Signer  string
-	State   string
+type Collaborator struct {
+	Name       string
+	Permission Permission
+
+	// True if Permission is granted by a direct or team association with the
+	// repository. If false, the permisssion is granted by the organization.
+	PermissionViaRepo bool
 }
diff --git a/pull/github.go b/pull/github.go
index 603de9c..e061b57 100644
--- a/pull/github.go
+++ b/pull/github.go
@@ -132,8 +132,9 @@ type GitHubContext struct {
 	comments      []*Comment
 	reviews       []*Review
 	reviewers     []*Reviewer
-	collaborators map[string]string
-	teams         map[string]string
+	collaborators []*Collaborator
+	permissions   map[string]Permission
+	teams         map[string]Permission
 	teamIDs       map[string]int64
 	membership    map[string]bool
 	statuses      map[string]string
@@ -309,44 +310,196 @@ func (ghc *GitHubContext) Reviews() ([]*Review, error) {
 	return ghc.reviews, nil
 }
 
-func (ghc *GitHubContext) RepositoryCollaborators() (map[string]string, error) {
+func (ghc *GitHubContext) RepositoryCollaborators() ([]*Collaborator, error) {
 	if ghc.collaborators == nil {
-		opts := github.ListCollaboratorsOptions{
-			ListOptions: github.ListOptions{
-				PerPage: 100,
-			},
+		// For reveiwer assignment, we need to figure out how each collaborator
+		// gets permissions on the repository. We _should_ be able to do this
+		// by examining the permission sources for each collaborator, but this
+		// API is innaccurate as of 2021-05-06. Specifically:
+		//
+		//   - Organization permissions are not reported correctly
+		//   - Triage/Maintain permissions are not supported
+		//   - Organization owners have both org and repo sources
+		//
+		// But even if this API was correct, it is not available to GitHub App
+		// integrations at this time.
+		//
+		// Instead, query for all collaborators and direct collaborators, then
+		// join that information with team permissions and membership to
+		// produce the final list of collaborators. This is expensive, but
+		// should only be used when assigning user reviewers, in which case
+		// almost all of the calls would have been made anyway.
+
+		var q struct {
+			Repository struct {
+				DirectCollaborators struct {
+					PageInfo v4PageInfo
+					Edges    []struct {
+						Permission string
+					}
+					Nodes []v4Actor
+				} `graphql:"direct: collaborators(affiliation: DIRECT, first: 100, after: $directCursor)"`
+				AllCollaborators struct {
+					PageInfo v4PageInfo
+					Edges    []struct {
+						Permission string
+					}
+					Nodes []v4Actor
+				} `graphql:"all: collaborators(affiliation: ALL, first: 100, after: $allCursor)"`
+			} `graphql:"repository(owner: $owner, name: $name)"`
+		}
+		qvars := map[string]interface{}{
+			"owner":        githubv4.String(ghc.owner),
+			"name":         githubv4.String(ghc.repo),
+			"directCursor": (*githubv4.String)(nil),
+			"allCursor":    (*githubv4.String)(nil),
 		}
 
-		collaborators := make(map[string]string)
+		directPerms := make(map[string]Permission)
+
+		var collaborators []*Collaborator
 		for {
-			users, res, err := ghc.client.Repositories.ListCollaborators(ghc.ctx, ghc.owner, ghc.repo, &opts)
-			if err != nil {
-				return nil, errors.Wrap(err, "failed to list repository collaborators")
+			complete := 0
+			if err := ghc.v4client.Query(ghc.ctx, &q, qvars); err != nil {
+				return nil, errors.Wrap(err, "failed to load repository collaborators")
 			}
-			for _, u := range users {
-				collaborators[u.GetLogin()] = coalescePermission(u.GetPermissions())
+
+			for i, u := range q.Repository.DirectCollaborators.Nodes {
+				edge := q.Repository.DirectCollaborators.Edges[i]
+				name := u.GetV3Login()
+
+				p, err := ParsePermission(edge.Permission)
+				if err != nil {
+					return nil, errors.Wrapf(err, "%s", name)
+				}
+				directPerms[name] = p
 			}
-			if res.NextPage == 0 {
+			if !q.Repository.DirectCollaborators.PageInfo.UpdateCursor(qvars, "directCursor") {
+				complete++
+			}
+
+			for i, u := range q.Repository.AllCollaborators.Nodes {
+				edge := q.Repository.AllCollaborators.Edges[i]
+				name := u.GetV3Login()
+
+				p, err := ParsePermission(edge.Permission)
+				if err != nil {
+					return nil, errors.Wrapf(err, "%s", name)
+				}
+
+				collaborators = append(collaborators, &Collaborator{
+					Name:       name,
+					Permission: p,
+				})
+			}
+			if !q.Repository.AllCollaborators.PageInfo.UpdateCursor(qvars, "allCursor") {
+				complete++
+			}
+
+			if complete == 2 {
 				break
 			}
-			opts.Page = res.NextPage
+		}
+
+		teamPerms, err := ghc.Teams()
+		if err != nil {
+			return nil, err
+		}
+
+		teamMembership := make(map[string][]string)
+		for team := range teamPerms {
+			// List full membership instead of testing each collaborator under
+			// the assumption that (teams * members) is much less than the
+			// total number of collaborators, which include those from the org
+			members, err := ghc.TeamMembers(ghc.owner + "/" + team)
+			if err != nil {
+				return nil, err
+			}
+
+			for _, member := range members {
+				teamMembership[member] = append(teamMembership[member], team)
+			}
+		}
+
+		isPermissionViaRepo := func(c *Collaborator) bool {
+			if directPerms[c.Name] >= c.Permission {
+				return true
+			}
+			for _, team := range teamMembership[c.Name] {
+				if teamPerms[team] >= c.Permission {
+					return true
+				}
+			}
+			return false
+		}
+
+		for _, c := range collaborators {
+			c.PermissionViaRepo = isPermissionViaRepo(c)
 		}
 		ghc.collaborators = collaborators
 	}
 	return ghc.collaborators, nil
 }
 
-func coalescePermission(perms map[string]bool) string {
-	// standardize to new-style values used by GraphQL and other endpoints
-	switch {
-	case perms["admin"]:
-		return "admin"
-	case perms["push"] || perms["write"]:
-		return "write"
-	case perms["pull"] || perms["read"]:
-		return "read"
+func (ghc *GitHubContext) CollaboratorPermission(user string) (Permission, error) {
+	if ghc.permissions == nil {
+		ghc.permissions = make(map[string]Permission)
+	}
+	if p, ok := ghc.permissions[user]; ok {
+		return p, nil
+	}
+
+	// Use GraphQL because the v3 API to get collaborator permissions does not
+	// support maintain and triage permissions as of 2021-05-07.
+	var q struct {
+		Repository struct {
+			Collaborators struct {
+				PageInfo v4PageInfo
+				Edges    []struct {
+					Permission string
+				}
+				Nodes []v4Actor
+			} `graphql:"collaborators(query: $user, first: 100, after: $cursor)"`
+		} `graphql:"repository(owner: $owner, name: $name)"`
 	}
-	return "none"
+	qvars := map[string]interface{}{
+		"owner":  githubv4.String(ghc.owner),
+		"name":   githubv4.String(ghc.repo),
+		"user":   githubv4.String(user),
+		"cursor": (*githubv4.String)(nil),
+	}
+
+	// The "query" argument does substring matching, so we might need to
+	// iterate through multiple users before we find the one we're looking for.
+	var perm Permission
+	for {
+		if err := ghc.v4client.Query(ghc.ctx, &q, qvars); err != nil {
+			return PermissionNone, errors.Wrap(err, "failed to get collaborator permission")
+		}
+		if idx := findUserIndex(user, q.Repository.Collaborators.Nodes); idx >= 0 {
+			p, err := ParsePermission(q.Repository.Collaborators.Edges[idx].Permission)
+			if err != nil {
+				return PermissionNone, err
+			}
+			perm = p
+			break
+		}
+		if !q.Repository.Collaborators.PageInfo.UpdateCursor(qvars, "cursor") {
+			break
+		}
+	}
+
+	ghc.permissions[user] = perm
+	return perm, nil
+}
+
+func findUserIndex(user string, users []v4Actor) int {
+	for i, u := range users {
+		if u.GetV3Login() == user {
+			return i
+		}
+	}
+	return -1
 }
 
 func (ghc *GitHubContext) RequestedReviewers() ([]*Reviewer, error) {
@@ -419,20 +572,20 @@ func (ghc *GitHubContext) loadRequestedReviewers() error {
 	return nil
 }
 
-func (ghc *GitHubContext) Teams() (map[string]string, error) {
+func (ghc *GitHubContext) Teams() (map[string]Permission, error) {
 	if ghc.teams == nil {
 		opt := &github.ListOptions{
 			PerPage: 100,
 		}
 
-		allTeams := make(map[string]string)
+		allTeams := make(map[string]Permission)
 		for {
-			teams, resp, err := ghc.client.Repositories.ListTeams(ghc.ctx, ghc.RepositoryOwner(), ghc.RepositoryName(), opt)
+			teams, resp, err := listTeams(ghc.ctx, ghc.client, ghc.owner, ghc.repo, opt)
 			if err != nil {
 				return nil, errors.Wrapf(err, "failed to list teams page %d", opt.Page)
 			}
 			for _, t := range teams {
-				allTeams[t.GetSlug()] = t.GetPermission()
+				allTeams[t.GetSlug()] = ParsePermissionMap(t.Permissions)
 			}
 			if resp.NextPage == 0 {
 				break
@@ -941,13 +1094,6 @@ func isNotFound(err error) bool {
 	return false
 }
 
-type SignatureType string
-
-const (
-	SignatureGpg   SignatureType = "GpgSignature"
-	SignatureSmime SignatureType = "SmimeSignature"
-)
-
 type v4GitSignature struct {
 	Type  string           `graphql:"__typename"`
 	GPG   v4GpgSignature   `graphql:"... on GpgSignature"`
diff --git a/pull/github_membership.go b/pull/github_membership.go
index 364c8bf..bcd94d9 100644
--- a/pull/github_membership.go
+++ b/pull/github_membership.go
@@ -94,15 +94,6 @@ func (mc *GitHubMembershipContext) IsOrgMember(org, user string) (bool, error) {
 	return isMember, nil
 }
 
-func (mc *GitHubMembershipContext) IsCollaborator(org, repo, user, desiredPerm string) (bool, error) {
-	perm, _, err := mc.client.Repositories.GetPermissionLevel(mc.ctx, org, repo, user)
-	if err != nil {
-		return false, errors.Wrapf(err, "failed to get repo %s permission", desiredPerm)
-	}
-
-	return perm.GetPermission() == desiredPerm, nil
-}
-
 func (mc *GitHubMembershipContext) OrganizationMembers(org string) ([]string, error) {
 	members, ok := mc.orgMembers[org]
 	if !ok {
diff --git a/pull/list_teams.go b/pull/list_teams.go
new file mode 100644
index 0000000..0483d01
--- /dev/null
+++ b/pull/list_teams.go
@@ -0,0 +1,84 @@
+// Copyright 2021 Palantir Technologies, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pull
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"reflect"
+
+	"github.com/google/go-github/v32/github"
+	"github.com/google/go-querystring/query"
+)
+
+// Based on RepositoriesService.ListTeams from google/go-github
+// Modified to include "permissions" map in response struct.
+//
+// ----
+//
+// Copyright 2013 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+//
+
+func listTeams(ctx context.Context, client *github.Client, owner string, repo string, opts *github.ListOptions) ([]*teamWithPermissions, *github.Response, error) {
+	u := fmt.Sprintf("repos/%v/%v/teams", owner, repo)
+	u, err := addOptions(u, opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := client.NewRequest("GET", u, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var teams []*teamWithPermissions
+	resp, err := client.Do(ctx, req, &teams)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return teams, resp, nil
+}
+
+type teamWithPermissions struct {
+	github.Team
+	Permissions map[string]bool `json:"permissions"`
+}
+
+// addOptions adds the parameters in opts as URL query parameters to s. opts
+// must be a struct whose fields may contain "url" tags.
+func addOptions(s string, opts interface{}) (string, error) {
+	v := reflect.ValueOf(opts)
+	if v.Kind() == reflect.Ptr && v.IsNil() {
+		return s, nil
+	}
+
+	u, err := url.Parse(s)
+	if err != nil {
+		return s, err
+	}
+
+	qs, err := query.Values(opts)
+	if err != nil {
+		return s, err
+	}
+
+	u.RawQuery = qs.Encode()
+	return u.String(), nil
+}
diff --git a/pull/permission.go b/pull/permission.go
new file mode 100644
index 0000000..90effa7
--- /dev/null
+++ b/pull/permission.go
@@ -0,0 +1,95 @@
+// Copyright 2021 Palantir Technologies, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pull
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Permission uint8
+
+const (
+	PermissionNone Permission = iota
+	PermissionRead
+	PermissionTriage
+	PermissionWrite
+	PermissionMaintain
+	PermissionAdmin
+)
+
+func ParsePermission(s string) (Permission, error) {
+	var p Permission
+	err := p.UnmarshalText([]byte(s))
+	return p, err
+}
+
+func ParsePermissionMap(m map[string]bool) Permission {
+	switch {
+	case m["admin"]:
+		return PermissionAdmin
+	case m["maintain"]:
+		return PermissionMaintain
+	case m["write"] || m["push"]:
+		return PermissionWrite
+	case m["triage"]:
+		return PermissionTriage
+	case m["read"] || m["pull"]:
+		return PermissionRead
+	}
+	return PermissionNone
+}
+
+func (p Permission) String() string {
+	switch p {
+	case PermissionNone:
+		return "none"
+	case PermissionRead:
+		return "read"
+	case PermissionTriage:
+		return "triage"
+	case PermissionWrite:
+		return "write"
+	case PermissionMaintain:
+		return "maintain"
+	case PermissionAdmin:
+		return "admin"
+	}
+	return fmt.Sprintf("unknown(%d)", p)
+}
+
+func (p Permission) MarshalText() ([]byte, error) {
+	return []byte(p.String()), nil
+}
+
+func (p *Permission) UnmarshalText(text []byte) error {
+	switch strings.ToLower(string(text)) {
+	case "none":
+		*p = PermissionNone
+	case "read":
+		*p = PermissionRead
+	case "triage":
+		*p = PermissionTriage
+	case "write":
+		*p = PermissionWrite
+	case "maintain":
+		*p = PermissionMaintain
+	case "admin":
+		*p = PermissionAdmin
+	default:
+		return fmt.Errorf("invalid permission: %s", text)
+	}
+	return nil
+}
diff --git a/server/handler/cross_org.go b/server/handler/cross_org.go
index 66302a1..63f886c 100644
--- a/server/handler/cross_org.go
+++ b/server/handler/cross_org.go
@@ -87,14 +87,6 @@ func (c *CrossOrgMembershipContext) IsOrgMember(org, user string) (bool, error)
 	return mbrCtx.IsOrgMember(org, user)
 }
 
-func (c *CrossOrgMembershipContext) IsCollaborator(org, repo, user, desiredPerm string) (bool, error) {
-	mbrCtx, err := c.getCtxForOrg(org)
-	if err != nil {
-		return false, err
-	}
-	return mbrCtx.IsCollaborator(org, repo, user, desiredPerm)
-}
-
 func (c *CrossOrgMembershipContext) OrganizationMembers(org string) ([]string, error) {
 	mbrCtx, err := c.getCtxForOrg(org)
 	if err != nil {
diff --git a/yarn.lock b/yarn.lock
index 36518fa..1c3704b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -814,9 +814,9 @@ hex-color-regex@^1.1.0:
   integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
 
 hosted-git-info@^2.1.4:
-  version "2.8.8"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
-  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hsl-regex@^1.0.0:
   version "1.0.0"
@@ -1063,9 +1063,9 @@ lodash.uniq@^4.5.0:
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
 lodash@^4.17.11, lodash@^4.17.20:
-  version "4.17.20"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
-  integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
 log-symbols@^2.2.0:
   version "2.2.0"


Test patch:
diff --git a/policy/common/actor_test.go b/policy/common/actor_test.go
index 5bdd3df..b2ea648 100644
--- a/policy/common/actor_test.go
+++ b/policy/common/actor_test.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"testing"
 
+	"github.com/palantir/policy-bot/pull"
 	"github.com/palantir/policy-bot/pull/pulltest"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -32,8 +33,9 @@ func TestIsActor(t *testing.T) {
 		OrgMemberships: map[string][]string{
 			"mhaypenny": {"cool-org", "regular-org"},
 		},
-		CollaboratorMemberships: map[string][]string{
-			"mhaypenny": {GithubAdminPermission, GithubWritePermission},
+		CollaboratorsValue: []*pull.Collaborator{
+			{Name: "mhaypenny", Permission: pull.PermissionAdmin},
+			{Name: "jstrawnickel", Permission: pull.PermissionWrite},
 		},
 	}
 
@@ -80,13 +82,25 @@ func TestIsActor(t *testing.T) {
 		a := &Actors{Admins: true}
 
 		assertActor(t, a, "mhaypenny")
+		assertNotActor(t, a, "jstrawnickel")
 		assertNotActor(t, a, "ttest")
 	})
 
 	t.Run("write", func(t *testing.T) {
 		a := &Actors{WriteCollaborators: true}
 
+		assertActor(t, a, "jstrawnickel")
+		assertNotActor(t, a, "mhaypenny")
+		assertNotActor(t, a, "ttest")
+	})
+
+	t.Run("permissions", func(t *testing.T) {
+		a := &Actors{
+			Permissions: []pull.Permission{pull.PermissionAdmin, pull.PermissionWrite},
+		}
+
 		assertActor(t, a, "mhaypenny")
+		assertActor(t, a, "jstrawnickel")
 		assertNotActor(t, a, "ttest")
 	})
 }
diff --git a/policy/reviewer/reviewer_test.go b/policy/reviewer/reviewer_test.go
index 42c3e94..1049686 100644
--- a/policy/reviewer/reviewer_test.go
+++ b/policy/reviewer/reviewer_test.go
@@ -18,7 +18,6 @@ import (
 	"context"
 	"errors"
 	"math/rand"
-	"sort"
 	"testing"
 
 	"github.com/palantir/policy-bot/policy/common"
@@ -29,14 +28,44 @@ import (
 )
 
 func TestFindLeafResults(t *testing.T) {
-	result := makeResult(&common.Result{
-		Name:              "Skipped",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusSkipped,
-		Error:             nil,
-		Children:          nil,
-	}, "random-users")
+	result := &common.Result{
+		Name:   "Parent",
+		Status: common.StatusPending,
+		Children: []*common.Result{
+			{
+				Name:              "One",
+				Status:            common.StatusPending,
+				ReviewRequestRule: &common.ReviewRequestRule{},
+			},
+			{
+				Name:              "Skipped",
+				Status:            common.StatusSkipped,
+				ReviewRequestRule: &common.ReviewRequestRule{},
+			},
+			{
+				Name:              "Error",
+				Status:            common.StatusPending,
+				Error:             errors.New("failed"),
+				ReviewRequestRule: &common.ReviewRequestRule{},
+			},
+			{
+				Name:              "Disapproved",
+				Status:            common.StatusDisapproved,
+				ReviewRequestRule: &common.ReviewRequestRule{},
+			},
+			{
+				Name:   "Two",
+				Status: common.StatusPending,
+				Children: []*common.Result{
+					{
+						Name:              "Three",
+						Status:            common.StatusPending,
+						ReviewRequestRule: &common.ReviewRequestRule{},
+					},
+				},
+			},
+		},
+	}
 	actualResults := FindRequests(result)
 	require.Len(t, actualResults, 2, "incorrect number of leaf results")
 }
@@ -156,33 +185,28 @@ func TestSelectRandomUsers(t *testing.T) {
 	assert.Equal(t, []string{"c", "e", "b", "f"}, multiplePseudoRandom)
 }
 
-func TestFindRepositoryCollaborators(t *testing.T) {
-	prctx := makeContext()
-	collabPerms, err := prctx.RepositoryCollaborators()
-	var collabs []string
-	for c := range collabPerms {
-		collabs = append(collabs, c)
-	}
-	sort.Strings(collabs)
-	require.NoError(t, err)
-	require.Equal(t, []string{"contributor-author", "contributor-committer", "mhaypenny", "org-owner", "review-approver", "user-direct-admin", "user-team-admin", "user-team-write"}, collabs)
-}
-
 func TestSelectReviewers(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Owner",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			Admins:        true,
-			RequiredCount: 1,
-			Mode:          common.RequestModeRandomUsers,
+	results := []*common.Result{
+		{
+			Name:   "users",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Users:         []string{"mhaypenny", "review-approver", "contributor-committer"},
+				RequiredCount: 2,
+				Mode:          common.RequestModeRandomUsers,
+			},
+		},
+		{
+			Name:   "admin-users",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Permissions:   []pull.Permission{pull.PermissionAdmin},
+				RequiredCount: 1,
+				Mode:          common.RequestModeRandomUsers,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "random-users")
+	}
 
 	prctx := makeContext()
 
@@ -195,107 +219,115 @@ func TestSelectReviewers(t *testing.T) {
 	require.NotContains(t, selection.Users, "org-owner", "org-owner should not be requested")
 }
 
-func TestSelectAdminTeam(t *testing.T) {
+func TestSelectReviewers_UserPermission(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Owner",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			Admins:        true,
-			RequiredCount: 1,
-			Mode:          "teams",
+	results := []*common.Result{
+		{
+			Name:   "user-permissions",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Permissions:   []pull.Permission{pull.PermissionTriage, pull.PermissionMaintain},
+				RequiredCount: 2,
+				Mode:          common.RequestModeAllUsers,
+			},
+		},
+	}
+
+	prctx := makeContext()
+
+	selection, err := SelectReviewers(context.Background(), prctx, results, r)
+	require.NoError(t, err)
+	require.Len(t, selection.Teams, 0, "policy should request no teams")
+
+	require.Len(t, selection.Users, 2, "policy should request two users")
+	require.Contains(t, selection.Users, "maintainer", "maintainer selected")
+	require.Contains(t, selection.Users, "triager", "triager selected")
+}
+
+func TestSelectReviewers_TeamPermission(t *testing.T) {
+	r := rand.New(rand.NewSource(42))
+	results := []*common.Result{
+		{
+			Name:   "team-permissions",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Permissions:   []pull.Permission{pull.PermissionAdmin, pull.PermissionMaintain},
+				RequiredCount: 1,
+				Mode:          common.RequestModeTeams,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "teams")
+	}
 
 	prctx := makeContext()
 
 	selection, err := SelectReviewers(context.Background(), prctx, results, r)
 	require.NoError(t, err)
-	require.Len(t, selection.Teams, 1, "admin team should be selected")
+	require.Len(t, selection.Teams, 2, "policy should request two teams")
 	require.Contains(t, selection.Teams, "team-admin", "admin team seleted")
+	require.Contains(t, selection.Teams, "team-maintain", "maintainer team seleted")
 
 	require.Len(t, selection.Users, 0, "policy should request no people")
 }
 
-func TestSelectReviewers_Team(t *testing.T) {
+func TestSelectReviewers_TeamMembers(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Team",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			// Require a team approval
-			Teams:         []string{"everyone/team-write"},
-			RequiredCount: 1,
-			Mode:          common.RequestModeRandomUsers,
+	results := []*common.Result{
+		{
+			Name:   "team-users",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Teams:         []string{"everyone/team-write"},
+				RequiredCount: 1,
+				Mode:          common.RequestModeRandomUsers,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "random-users")
+	}
 
 	prctx := makeContext()
 	selection, err := SelectReviewers(context.Background(), prctx, results, r)
 	require.NoError(t, err)
 	require.Empty(t, selection.Teams, "no teams should be returned")
-	require.Len(t, selection.Users, 3, "policy should request three people")
-	require.Contains(t, selection.Users, "review-approver", "at least review-approver must be selected")
-	require.Contains(t, selection.Users, "user-team-write", "at least user-team-write must be selected")
-	require.NotContains(t, selection.Users, "mhaypenny", "the author cannot be requested")
-	require.NotContains(t, selection.Users, "not-a-collaborator", "a non collaborator cannot be requested")
+	require.Len(t, selection.Users, 1, "policy should request one reviewer")
+	require.Contains(t, selection.Users, "user-team-write", "user-team-write must be selected")
 }
 
-func TestSelectReviewers_Team_teams(t *testing.T) {
+func TestSelectReviewers_Team(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Team",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			// Require a team approval
-			Teams:         []string{"everyone/team-write", "everyone/team-not-collaborators"},
-			Users:         []string{"user-team-write"},
-			RequiredCount: 1,
-			Mode:          "teams",
+	results := []*common.Result{
+		{
+			Name:   "Team",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Teams:         []string{"everyone/team-write", "everyone/team-not-collaborators"},
+				Users:         []string{"user-team-write"},
+				RequiredCount: 1,
+				Mode:          common.RequestModeTeams,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "random-users")
+	}
 
 	prctx := makeContext()
 	selection, err := SelectReviewers(context.Background(), prctx, results, r)
 	require.NoError(t, err)
 	require.Len(t, selection.Teams, 1, "one team should be returned")
 	require.Contains(t, selection.Teams, "team-write", "team-write should be selected")
-	require.Len(t, selection.Users, 2, "policy should request 2 people")
-	require.Contains(t, selection.Users, "review-approver", "at least review-approver must be selected")
-	require.NotContains(t, selection.Users, "user-team-write", "user-team-write should not be selected")
-	require.NotContains(t, selection.Users, "mhaypenny", "the author cannot be requested")
-	require.NotContains(t, selection.Users, "not-a-collaborator", "a non collaborator cannot be requested")
+	require.Len(t, selection.Users, 0, "policy should request 0 users")
 }
 
-func TestSelectReviewers_Team_teamsDefaultsToNothing(t *testing.T) {
+func TestSelectReviewers_TeamNotCollaborator(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Team",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			// Require a team approval
-			Teams:         []string{"everyone/team-not-collaborators"},
-			Users:         []string{"user-team-write"},
-			RequiredCount: 1,
-			Mode:          "teams",
+	results := []*common.Result{
+		{
+			Name:   "not-collaborators",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Teams:         []string{"everyone/team-not-collaborators"},
+				Users:         []string{"user-team-write"},
+				RequiredCount: 1,
+				Mode:          common.RequestModeTeams,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "teams")
+	}
 
 	prctx := makeContext()
 	selection, err := SelectReviewers(context.Background(), prctx, results, r)
@@ -306,97 +338,23 @@ func TestSelectReviewers_Team_teamsDefaultsToNothing(t *testing.T) {
 
 func TestSelectReviewers_Org(t *testing.T) {
 	r := rand.New(rand.NewSource(42))
-	results := makeResults(&common.Result{
-		Name:              "Team",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			// Require everyone org approval
-			Organizations: []string{"everyone"},
-			RequiredCount: 1,
-			Mode:          common.RequestModeRandomUsers,
+	results := []*common.Result{
+		{
+			Name:   "org",
+			Status: common.StatusPending,
+			ReviewRequestRule: &common.ReviewRequestRule{
+				Organizations: []string{"everyone"},
+				RequiredCount: 1,
+				Mode:          common.RequestModeRandomUsers,
+			},
 		},
-		Error:    nil,
-		Children: nil,
-	}, "random-users")
+	}
 
 	prctx := makeContext()
 	selection, err := SelectReviewers(context.Background(), prctx, results, r)
 	require.NoError(t, err)
-	require.Len(t, selection.Users, 3, "policy should request three people")
-	require.Contains(t, selection.Users, "review-approver", "at least review-approver must be selected")
-	require.NotContains(t, selection.Users, "mhaypenny", "the author cannot be requested")
-	require.NotContains(t, selection.Users, "not-a-collaborator", "a non collaborator cannot be requested")
-}
-
-func makeResults(result *common.Result, mode string) []*common.Result {
-	return FindRequests(makeResult(result, mode))
-}
-
-func makeResult(result *common.Result, mode string) *common.Result {
-	return &common.Result{
-		Name:              "One",
-		Description:       "",
-		StatusDescription: "",
-		Status:            common.StatusPending,
-		ReviewRequestRule: &common.ReviewRequestRule{
-			Users:         []string{"neverappears"},
-			RequiredCount: 0,
-			Mode:          common.RequestMode(mode),
-		},
-		Error: nil,
-		Children: []*common.Result{
-			{
-				Name:              "Two",
-				Description:       "",
-				StatusDescription: "",
-				Status:            common.StatusPending,
-				ReviewRequestRule: &common.ReviewRequestRule{
-					Users:         []string{"mhaypenny", "review-approver"},
-					RequiredCount: 1,
-					Mode:          common.RequestMode(mode),
-				},
-				Error:    nil,
-				Children: nil,
-			},
-			result,
-			{
-				Name:              "Three",
-				Description:       "",
-				StatusDescription: "",
-				Status:            common.StatusDisapproved,
-				ReviewRequestRule: &common.ReviewRequestRule{},
-				Error:             errors.New("foo"),
-				Children:          nil,
-			},
-			{
-				Name:              "Four",
-				Description:       "",
-				StatusDescription: "",
-				Status:            common.StatusPending,
-				ReviewRequestRule: &common.ReviewRequestRule{},
-				Error:             nil,
-				Children: []*common.Result{
-					{
-						Name:              "Five",
-						Description:       "",
-						StatusDescription: "",
-						Status:            common.StatusPending,
-						ReviewRequestRule: &common.ReviewRequestRule{
-							Users:              []string{"contributor-committer", "contributor-author", "not-a-collaborator"},
-							RequiredCount:      1,
-							WriteCollaborators: true,
-							Admins:             false,
-							Mode:               common.RequestMode(mode),
-						},
-						Error:    nil,
-						Children: nil,
-					},
-				},
-			},
-		},
-	}
+	require.Len(t, selection.Users, 1, "policy should request one person")
+	require.Contains(t, selection.Users, "review-approver", "review-approver must be selected")
 }
 
 func makeContext() pull.Context {
@@ -414,23 +372,29 @@ func makeContext() pull.Context {
 			"comment-approver":      {"everyone", "cool-org"},
 			"review-approver":       {"everyone", "even-cooler-org"},
 		},
-		CollaboratorMemberships: map[string][]string{
-			"mhaypenny":             {common.GithubAdminPermission},
-			"org-owner":             {common.GithubAdminPermission},
-			"user-team-admin":       {common.GithubAdminPermission},
-			"user-direct-admin":     {common.GithubAdminPermission},
-			"user-team-write":       {common.GithubWritePermission},
-			"contributor-committer": {common.GithubWritePermission},
-			"contributor-author":    {common.GithubWritePermission},
-			"review-approver":       {common.GithubWritePermission},
+		CollaboratorsValue: []*pull.Collaborator{
+			{Name: "mhaypenny", Permission: pull.PermissionAdmin},
+			{Name: "org-owner", Permission: pull.PermissionAdmin},
+			{Name: "user-team-admin", Permission: pull.PermissionAdmin, PermissionViaRepo: true},
+			{Name: "user-direct-admin", Permission: pull.PermissionAdmin, PermissionViaRepo: true},
+			{Name: "user-team-write", Permission: pull.PermissionWrite, PermissionViaRepo: true},
+			{Name: "contributor-committer", Permission: pull.PermissionWrite},
+			{Name: "contributor-author", Permission: pull.PermissionWrite},
+			{Name: "review-approver", Permission: pull.PermissionWrite},
+			{Name: "maintainer", Permission: pull.PermissionMaintain, PermissionViaRepo: true},
+			{Name: "indirect-maintainer", Permission: pull.PermissionMaintain}, // note: currently not possible in GitHub
+			{Name: "triager", Permission: pull.PermissionTriage, PermissionViaRepo: true},
+			{Name: "indirect-triager", Permission: pull.PermissionTriage}, // note: currently not possible in GitHub
 		},
-		TeamsValue: map[string]string{
-			"team-write": common.GithubWritePermission,
-			"team-admin": common.GithubAdminPermission,
+		TeamsValue: map[string]pull.Permission{
+			"team-write":    pull.PermissionWrite,
+			"team-admin":    pull.PermissionAdmin,
+			"team-maintain": pull.PermissionMaintain,
 		},
 		TeamMemberships: map[string][]string{
 			"user-team-admin":    {"everyone/team-admin"},
 			"user-team-write":    {"everyone/team-write"},
+			"maintainer":         {"everyone/team-maintain"},
 			"not-a-collaborator": {"everyone/team-not-collaborators"},
 		},
 	}
diff --git a/pull/github_test.go b/pull/github_test.go
index dd3e492..fb90bc7 100644
--- a/pull/github_test.go
+++ b/pull/github_test.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"net/http"
 	"net/url"
+	"sort"
 	"testing"
 	"time"
 
@@ -437,6 +438,95 @@ func TestCrossRepoBranches(t *testing.T) {
 	assert.Equal(t, "testorg2:test-branch", head, "cross-repo head branch was not correctly set")
 }
 
+func TestCollaboratorPermission(t *testing.T) {
+	rp := &ResponsePlayer{}
+	rule := rp.AddRule(
+		GraphQLNodePrefixMatcher("repository.collaborators"),
+		"testdata/responses/repo_collaborator_permission.yml",
+	)
+
+	ctx := makeContext(t, rp, nil)
+
+	p, err := ctx.CollaboratorPermission("direct-admin")
+	require.NoError(t, err)
+	assert.Equal(t, PermissionAdmin, p, "incorrect permission for direct-admin")
+	assert.Equal(t, 1, rule.Count, "incorrect http request count")
+
+	rule.Count = 0
+
+	p, err = ctx.CollaboratorPermission("direct")
+	require.NoError(t, err)
+	assert.Equal(t, PermissionNone, p, "incorrect permission for missing user")
+	assert.Equal(t, 2, rule.Count, "incorrect http request count")
+
+	p, err = ctx.CollaboratorPermission("direct-admin")
+	require.NoError(t, err)
+	assert.Equal(t, PermissionAdmin, p, "incorrect permission for direct-admin")
+	assert.Equal(t, 2, rule.Count, "cached data was not used on second request")
+
+	p, err = ctx.CollaboratorPermission("team-maintain")
+	require.NoError(t, err)
+	assert.Equal(t, PermissionMaintain, p, "incorrect permission for team-maintain")
+}
+
+func TestRepositoryCollaborators(t *testing.T) {
+	rp := &ResponsePlayer{}
+	rp.AddRule(
+		ExactPathMatcher("/repos/testorg/testrepo/teams"),
+		"testdata/responses/repo_teams.yml",
+	)
+	rp.AddRule(
+		ExactPathMatcher("/orgs/testorg/teams/maintainers/members"),
+		"testdata/responses/repo_team_members_maintainers.yml",
+	)
+	rp.AddRule(
+		ExactPathMatcher("/orgs/testorg/teams/admins/members"),
+		"testdata/responses/repo_team_members_admins.yml",
+	)
+	rp.AddRule(
+		GraphQLNodePrefixMatcher("repository.collaborators"),
+		"testdata/responses/repo_collaborators.yml",
+	)
+
+	ctx := makeContext(t, rp, nil)
+
+	collaborators, err := ctx.RepositoryCollaborators()
+	require.NoError(t, err)
+
+	require.Len(t, collaborators, 6, "incorrect number of collaborators")
+	sort.Slice(collaborators, func(i, j int) bool { return collaborators[i].Name < collaborators[j].Name })
+
+	c0 := collaborators[0]
+	assert.Equal(t, "direct-admin", c0.Name)
+	assert.Equal(t, PermissionAdmin, c0.Permission)
+	assert.Equal(t, true, c0.PermissionViaRepo)
+
+	c1 := collaborators[1]
+	assert.Equal(t, "direct-triage", c1.Name)
+	assert.Equal(t, PermissionTriage, c1.Permission)
+	assert.Equal(t, true, c1.PermissionViaRepo)
+
+	c2 := collaborators[2]
+	assert.Equal(t, "org-owner", c2.Name)
+	assert.Equal(t, PermissionAdmin, c2.Permission)
+	assert.Equal(t, false, c2.PermissionViaRepo)
+
+	c3 := collaborators[3]
+	assert.Equal(t, "org-read", c3.Name)
+	assert.Equal(t, PermissionRead, c3.Permission)
+	assert.Equal(t, false, c3.PermissionViaRepo)
+
+	c4 := collaborators[4]
+	assert.Equal(t, "team-admin", c4.Name)
+	assert.Equal(t, PermissionAdmin, c4.Permission)
+	assert.Equal(t, true, c4.PermissionViaRepo)
+
+	c5 := collaborators[5]
+	assert.Equal(t, "team-maintain", c5.Name)
+	assert.Equal(t, PermissionMaintain, c5.Permission)
+	assert.Equal(t, true, c5.PermissionViaRepo)
+}
+
 func makeContext(t *testing.T, rp *ResponsePlayer, pr *github.PullRequest) Context {
 	ctx := context.Background()
 	client := github.NewClient(&http.Client{Transport: rp})
diff --git a/pull/pulltest/context.go b/pull/pulltest/context.go
index 9d3edc4..a53ddd3 100644
--- a/pull/pulltest/context.go
+++ b/pull/pulltest/context.go
@@ -49,14 +49,14 @@ type Context struct {
 	TeamMemberships     map[string][]string
 	TeamMembershipError error
 
-	TeamsValue map[string]string
+	TeamsValue map[string]pull.Permission
 	TeamsError error
 
 	OrgMemberships     map[string][]string
 	OrgMembershipError error
 
-	CollaboratorMemberships     map[string][]string
-	CollaboratorMembershipError error
+	CollaboratorsValue []*pull.Collaborator
+	CollaboratorsError error
 
 	RequestedReviewersValue []*pull.Reviewer
 	RequestedReviewersError error
@@ -157,28 +157,23 @@ func (c *Context) IsOrgMember(org, user string) (bool, error) {
 	return false, nil
 }
 
-func (c *Context) IsCollaborator(org, repo, user, desiredPerm string) (bool, error) {
-	if c.CollaboratorMembershipError != nil {
-		return false, c.CollaboratorMembershipError
+func (c *Context) CollaboratorPermission(user string) (pull.Permission, error) {
+	if c.CollaboratorsError != nil {
+		return pull.PermissionNone, c.CollaboratorsError
 	}
-
-	for _, c := range c.CollaboratorMemberships[user] {
-		if c == desiredPerm {
-			return true, nil
+	for _, collab := range c.CollaboratorsValue {
+		if collab.Name == user {
+			return collab.Permission, nil
 		}
 	}
-	return false, nil
+	return pull.PermissionNone, nil
 }
 
-func (c *Context) RepositoryCollaborators() (map[string]string, error) {
-	if c.CollaboratorMembershipError != nil {
-		return nil, c.CollaboratorMembershipError
-	}
-	users := make(map[string]string)
-	for u, p := range c.CollaboratorMemberships {
-		users[u] = p[0]
+func (c *Context) RepositoryCollaborators() ([]*pull.Collaborator, error) {
+	if c.CollaboratorsError != nil {
+		return nil, c.CollaboratorsError
 	}
-	return users, nil
+	return c.CollaboratorsValue, nil
 }
 
 func (c *Context) OrganizationMembers(org string) ([]string, error) {
@@ -232,7 +227,7 @@ func (c *Context) Reviews() ([]*pull.Review, error) {
 	return c.ReviewsValue, c.ReviewsError
 }
 
-func (c *Context) Teams() (map[string]string, error) {
+func (c *Context) Teams() (map[string]pull.Permission, error) {
 	return c.TeamsValue, c.TeamsError
 }
 
diff --git a/pull/testdata/responses/repo_collaborator_permission.yml b/pull/testdata/responses/repo_collaborator_permission.yml
new file mode 100644
index 0000000..731488a
--- /dev/null
+++ b/pull/testdata/responses/repo_collaborator_permission.yml
@@ -0,0 +1,80 @@
+- status: 200
+  body: |
+    {
+      "errors": [],
+      "data": {
+        "repository": {
+          "collaborators": {
+            "pageInfo": {
+              "endCursor": "3",
+              "hasNextPage": true
+            },
+            "edges": [
+              {
+                "permission": "ADMIN"
+              },
+              {
+                "permission": "READ"
+              },
+              {
+                "permission": "ADMIN"
+              }
+            ],
+            "nodes": [
+              {
+                "__typename": "User",
+                "login": "org-owner"
+              },
+              {
+                "__typename": "User",
+                "login": "org-read"
+              },
+              {
+                "__typename": "User",
+                "login": "direct-admin"
+              }
+            ]
+          }
+        }
+      }
+    }
+- status: 200
+  body: |
+    {
+      "errors": [],
+      "data": {
+        "repository": {
+          "collaborators": {
+            "pageInfo": {
+              "endCursor": "6",
+              "hasNextPage": false
+            },
+            "edges": [
+              {
+                "permission": "TRIAGE"
+              },
+              {
+                "permission": "MAINTAIN"
+              },
+              {
+                "permission": "ADMIN"
+              }
+            ],
+            "nodes": [
+              {
+                "__typename": "User",
+                "login": "direct-triage"
+              },
+              {
+                "__typename": "User",
+                "login": "team-maintain"
+              },
+              {
+                "__typename": "User",
+                "login": "team-admin"
+              }
+            ]
+          }
+        }
+      }
+    }
diff --git a/pull/testdata/responses/repo_collaborators.yml b/pull/testdata/responses/repo_collaborators.yml
new file mode 100644
index 0000000..23ca9cf
--- /dev/null
+++ b/pull/testdata/responses/repo_collaborators.yml
@@ -0,0 +1,112 @@
+- status: 200
+  body: |
+    {
+      "errors": [],
+      "data": {
+        "repository": {
+          "direct": {
+            "pageInfo": {
+              "endCursor": "2",
+              "hasNextPage": false
+            },
+            "edges": [
+              {
+                "permission": "ADMIN"
+              },
+              {
+                "permission": "TRIAGE"
+              }
+            ],
+            "nodes": [
+              {
+                "__typename": "User",
+                "login": "direct-admin"
+              },
+              {
+                "__typename": "User",
+                "login": "direct-triage"
+              }
+            ]
+          },
+          "all": {
+            "pageInfo": {
+              "endCursor": "3",
+              "hasNextPage": true
+            },
+            "edges": [
+              {
+                "permission": "ADMIN"
+              },
+              {
+                "permission": "READ"
+              },
+              {
+                "permission": "ADMIN"
+              }
+            ],
+            "nodes": [
+              {
+                "__typename": "User",
+                "login": "org-owner"
+              },
+              {
+                "__typename": "User",
+                "login": "org-read"
+              },
+              {
+                "__typename": "User",
+                "login": "direct-admin"
+              }
+            ]
+          }
+        }
+      }
+    }
+- status: 200
+  body: |
+    {
+      "errors": [],
+      "data": {
+        "repository": {
+          "direct": {
+            "pageInfo": {
+              "endCursor": "2",
+              "hasNextPage": false
+            },
+            "edges": [],
+            "nodes": []
+          },
+          "all": {
+            "pageInfo": {
+              "endCursor": "6",
+              "hasNextPage": false
+            },
+            "edges": [
+              {
+                "permission": "TRIAGE"
+              },
+              {
+                "permission": "MAINTAIN"
+              },
+              {
+                "permission": "ADMIN"
+              }
+            ],
+            "nodes": [
+              {
+                "__typename": "User",
+                "login": "direct-triage"
+              },
+              {
+                "__typename": "User",
+                "login": "team-maintain"
+              },
+              {
+                "__typename": "User",
+                "login": "team-admin"
+              }
+            ]
+          }
+        }
+      }
+    }
diff --git a/pull/testdata/responses/repo_team_members_admins.yml b/pull/testdata/responses/repo_team_members_admins.yml
new file mode 100644
index 0000000..e3a7051
--- /dev/null
+++ b/pull/testdata/responses/repo_team_members_admins.yml
@@ -0,0 +1,9 @@
+- status: 200
+  body: |
+    [
+      {
+        "login": "team-admin",
+        "type": "User"
+      }
+    ]
+
diff --git a/pull/testdata/responses/repo_team_members_maintainers.yml b/pull/testdata/responses/repo_team_members_maintainers.yml
new file mode 100644
index 0000000..ebb34b0
--- /dev/null
+++ b/pull/testdata/responses/repo_team_members_maintainers.yml
@@ -0,0 +1,9 @@
+- status: 200
+  body: |
+    [
+      {
+        "login": "team-maintain",
+        "type": "User"
+      }
+    ]
+
diff --git a/pull/testdata/responses/repo_teams.yml b/pull/testdata/responses/repo_teams.yml
new file mode 100644
index 0000000..299d9c5
--- /dev/null
+++ b/pull/testdata/responses/repo_teams.yml
@@ -0,0 +1,28 @@
+- status: 200
+  body: |
+    [
+      {
+        "name": "Maintainers",
+        "slug": "maintainers",
+        "permission": "push",
+        "permissions": {
+          "pull": false,
+          "triage": false,
+          "push": false,
+          "maintain": true,
+          "admin": false
+        }
+      },
+      {
+        "name": "Admins",
+        "slug": "admins",
+        "permission": "admin",
+        "permissions": {
+          "pull": true,
+          "triage": false,
+          "push": true,
+          "maintain": false,
+          "admin": true
+        }
+      }
+    ]