github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/api/queries_repo.go (about)

     1  package api
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"net/http"
     9  	"sort"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/abdfnx/gh-api/internal/ghrepo"
    14  	"github.com/shurcooL/githubv4"
    15  )
    16  
    17  // Repository contains information about a GitHub repo
    18  type Repository struct {
    19  	ID          string
    20  	Name        string
    21  	Description string
    22  	URL         string
    23  	CloneURL    string
    24  	CreatedAt   time.Time
    25  	Owner       RepositoryOwner
    26  
    27  	IsPrivate        bool
    28  	HasIssuesEnabled bool
    29  	HasWikiEnabled   bool
    30  	ViewerPermission string
    31  	DefaultBranchRef BranchRef
    32  
    33  	Parent *Repository
    34  
    35  	MergeCommitAllowed bool
    36  	RebaseMergeAllowed bool
    37  	SquashMergeAllowed bool
    38  
    39  	// pseudo-field that keeps track of host name of this repo
    40  	hostname string
    41  }
    42  
    43  // RepositoryOwner is the owner of a GitHub repository
    44  type RepositoryOwner struct {
    45  	Login string
    46  }
    47  
    48  // BranchRef is the branch name in a GitHub repository
    49  type BranchRef struct {
    50  	Name string
    51  }
    52  
    53  // RepoOwner is the login name of the owner
    54  func (r Repository) RepoOwner() string {
    55  	return r.Owner.Login
    56  }
    57  
    58  // RepoName is the name of the repository
    59  func (r Repository) RepoName() string {
    60  	return r.Name
    61  }
    62  
    63  // RepoHost is the GitHub hostname of the repository
    64  func (r Repository) RepoHost() string {
    65  	return r.hostname
    66  }
    67  
    68  // IsFork is true when this repository has a parent repository
    69  func (r Repository) IsFork() bool {
    70  	return r.Parent != nil
    71  }
    72  
    73  // ViewerCanPush is true when the requesting user has push access
    74  func (r Repository) ViewerCanPush() bool {
    75  	switch r.ViewerPermission {
    76  	case "ADMIN", "MAINTAIN", "WRITE":
    77  		return true
    78  	default:
    79  		return false
    80  	}
    81  }
    82  
    83  // ViewerCanTriage is true when the requesting user can triage issues and pull requests
    84  func (r Repository) ViewerCanTriage() bool {
    85  	switch r.ViewerPermission {
    86  	case "ADMIN", "MAINTAIN", "WRITE", "TRIAGE":
    87  		return true
    88  	default:
    89  		return false
    90  	}
    91  }
    92  
    93  func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
    94  	query := `
    95  	fragment repo on Repository {
    96  		id
    97  		name
    98  		owner { login }
    99  		hasIssuesEnabled
   100  		description
   101  		hasWikiEnabled
   102  		viewerPermission
   103  		defaultBranchRef {
   104  			name
   105  		}
   106  	}
   107  
   108  	query RepositoryInfo($owner: String!, $name: String!) {
   109  		repository(owner: $owner, name: $name) {
   110  			...repo
   111  			parent {
   112  				...repo
   113  			}
   114  			mergeCommitAllowed
   115  			rebaseMergeAllowed
   116  			squashMergeAllowed
   117  		}
   118  	}`
   119  	variables := map[string]interface{}{
   120  		"owner": repo.RepoOwner(),
   121  		"name":  repo.RepoName(),
   122  	}
   123  
   124  	result := struct {
   125  		Repository Repository
   126  	}{}
   127  	err := client.GraphQL(repo.RepoHost(), query, variables, &result)
   128  
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	return InitRepoHostname(&result.Repository, repo.RepoHost()), nil
   134  }
   135  
   136  func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) {
   137  	if r, ok := repo.(*Repository); ok && r.DefaultBranchRef.Name != "" {
   138  		return r.DefaultBranchRef.Name, nil
   139  	}
   140  
   141  	r, err := GitHubRepo(client, repo)
   142  	if err != nil {
   143  		return "", err
   144  	}
   145  	return r.DefaultBranchRef.Name, nil
   146  }
   147  
   148  func CanPushToRepo(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
   149  	if r, ok := repo.(*Repository); ok && r.ViewerPermission != "" {
   150  		return r.ViewerCanPush(), nil
   151  	}
   152  
   153  	apiClient := NewClientFromHTTP(httpClient)
   154  	r, err := GitHubRepo(apiClient, repo)
   155  	if err != nil {
   156  		return false, err
   157  	}
   158  	return r.ViewerCanPush(), nil
   159  }
   160  
   161  // RepoParent finds out the parent repository of a fork
   162  func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) {
   163  	var query struct {
   164  		Repository struct {
   165  			Parent *struct {
   166  				Name  string
   167  				Owner struct {
   168  					Login string
   169  				}
   170  			}
   171  		} `graphql:"repository(owner: $owner, name: $name)"`
   172  	}
   173  
   174  	variables := map[string]interface{}{
   175  		"owner": githubv4.String(repo.RepoOwner()),
   176  		"name":  githubv4.String(repo.RepoName()),
   177  	}
   178  
   179  	gql := graphQLClient(client.http, repo.RepoHost())
   180  	err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
   181  	if err != nil {
   182  		return nil, err
   183  	}
   184  	if query.Repository.Parent == nil {
   185  		return nil, nil
   186  	}
   187  
   188  	parent := ghrepo.NewWithHost(query.Repository.Parent.Owner.Login, query.Repository.Parent.Name, repo.RepoHost())
   189  	return parent, nil
   190  }
   191  
   192  // RepoNetworkResult describes the relationship between related repositories
   193  type RepoNetworkResult struct {
   194  	ViewerLogin  string
   195  	Repositories []*Repository
   196  }
   197  
   198  // RepoNetwork inspects the relationship between multiple GitHub repositories
   199  func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, error) {
   200  	var hostname string
   201  	if len(repos) > 0 {
   202  		hostname = repos[0].RepoHost()
   203  	}
   204  
   205  	queries := make([]string, 0, len(repos))
   206  	for i, repo := range repos {
   207  		queries = append(queries, fmt.Sprintf(`
   208  		repo_%03d: repository(owner: %q, name: %q) {
   209  			...repo
   210  			parent {
   211  				...repo
   212  			}
   213  		}
   214  		`, i, repo.RepoOwner(), repo.RepoName()))
   215  	}
   216  
   217  	// Since the query is constructed dynamically, we can't parse a response
   218  	// format using a static struct. Instead, hold the raw JSON data until we
   219  	// decide how to parse it manually.
   220  	graphqlResult := make(map[string]*json.RawMessage)
   221  	var result RepoNetworkResult
   222  
   223  	err := client.GraphQL(hostname, fmt.Sprintf(`
   224  	fragment repo on Repository {
   225  		id
   226  		name
   227  		owner { login }
   228  		viewerPermission
   229  		defaultBranchRef {
   230  			name
   231  		}
   232  		isPrivate
   233  	}
   234  	query RepositoryNetwork {
   235  		viewer { login }
   236  		%s
   237  	}
   238  	`, strings.Join(queries, "")), nil, &graphqlResult)
   239  	graphqlError, isGraphQLError := err.(*GraphQLErrorResponse)
   240  	if isGraphQLError {
   241  		// If the only errors are that certain repositories are not found,
   242  		// continue processing this response instead of returning an error
   243  		tolerated := true
   244  		for _, ge := range graphqlError.Errors {
   245  			if ge.Type != "NOT_FOUND" {
   246  				tolerated = false
   247  			}
   248  		}
   249  		if tolerated {
   250  			err = nil
   251  		}
   252  	}
   253  	if err != nil {
   254  		return result, err
   255  	}
   256  
   257  	keys := make([]string, 0, len(graphqlResult))
   258  	for key := range graphqlResult {
   259  		keys = append(keys, key)
   260  	}
   261  	// sort keys to ensure `repo_{N}` entries are processed in order
   262  	sort.Strings(keys)
   263  
   264  	// Iterate over keys of GraphQL response data and, based on its name,
   265  	// dynamically allocate the target struct an individual message gets decoded to.
   266  	for _, name := range keys {
   267  		jsonMessage := graphqlResult[name]
   268  		if name == "viewer" {
   269  			viewerResult := struct {
   270  				Login string
   271  			}{}
   272  			decoder := json.NewDecoder(bytes.NewReader([]byte(*jsonMessage)))
   273  			if err := decoder.Decode(&viewerResult); err != nil {
   274  				return result, err
   275  			}
   276  			result.ViewerLogin = viewerResult.Login
   277  		} else if strings.HasPrefix(name, "repo_") {
   278  			if jsonMessage == nil {
   279  				result.Repositories = append(result.Repositories, nil)
   280  				continue
   281  			}
   282  			var repo Repository
   283  			decoder := json.NewDecoder(bytes.NewReader(*jsonMessage))
   284  			if err := decoder.Decode(&repo); err != nil {
   285  				return result, err
   286  			}
   287  			result.Repositories = append(result.Repositories, InitRepoHostname(&repo, hostname))
   288  		} else {
   289  			return result, fmt.Errorf("unknown GraphQL result key %q", name)
   290  		}
   291  	}
   292  	return result, nil
   293  }
   294  
   295  func InitRepoHostname(repo *Repository, hostname string) *Repository {
   296  	repo.hostname = hostname
   297  	if repo.Parent != nil {
   298  		repo.Parent.hostname = hostname
   299  	}
   300  	return repo
   301  }
   302  
   303  // repositoryV3 is the repository result from GitHub API v3
   304  type repositoryV3 struct {
   305  	NodeID    string
   306  	Name      string
   307  	CreatedAt time.Time `json:"created_at"`
   308  	CloneURL  string    `json:"clone_url"`
   309  	Owner     struct {
   310  		Login string
   311  	}
   312  }
   313  
   314  // ForkRepo forks the repository on GitHub and returns the new repository
   315  func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
   316  	path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
   317  	body := bytes.NewBufferString(`{}`)
   318  	result := repositoryV3{}
   319  	err := client.REST(repo.RepoHost(), "POST", path, body, &result)
   320  	if err != nil {
   321  		return nil, err
   322  	}
   323  
   324  	return &Repository{
   325  		ID:        result.NodeID,
   326  		Name:      result.Name,
   327  		CloneURL:  result.CloneURL,
   328  		CreatedAt: result.CreatedAt,
   329  		Owner: RepositoryOwner{
   330  			Login: result.Owner.Login,
   331  		},
   332  		ViewerPermission: "WRITE",
   333  		hostname:         repo.RepoHost(),
   334  	}, nil
   335  }
   336  
   337  // RepoFindForks finds forks of the repo that are affiliated with the viewer
   338  func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) {
   339  	result := struct {
   340  		Repository struct {
   341  			Forks struct {
   342  				Nodes []Repository
   343  			}
   344  		}
   345  	}{}
   346  
   347  	variables := map[string]interface{}{
   348  		"owner": repo.RepoOwner(),
   349  		"repo":  repo.RepoName(),
   350  		"limit": limit,
   351  	}
   352  
   353  	if err := client.GraphQL(repo.RepoHost(), `
   354  	query RepositoryFindFork($owner: String!, $repo: String!, $limit: Int!) {
   355  		repository(owner: $owner, name: $repo) {
   356  			forks(first: $limit, affiliations: [OWNER, COLLABORATOR]) {
   357  				nodes {
   358  					id
   359  					name
   360  					owner { login }
   361  					url
   362  					viewerPermission
   363  				}
   364  			}
   365  		}
   366  	}
   367  	`, variables, &result); err != nil {
   368  		return nil, err
   369  	}
   370  
   371  	var results []*Repository
   372  	for _, r := range result.Repository.Forks.Nodes {
   373  		// we check ViewerCanPush, even though we expect it to always be true per
   374  		// `affiliations` condition, to guard against versions of GitHub with a
   375  		// faulty `affiliations` implementation
   376  		if !r.ViewerCanPush() {
   377  			continue
   378  		}
   379  		results = append(results, InitRepoHostname(&r, repo.RepoHost()))
   380  	}
   381  
   382  	return results, nil
   383  }
   384  
   385  type RepoMetadataResult struct {
   386  	AssignableUsers []RepoAssignee
   387  	Labels          []RepoLabel
   388  	Projects        []RepoProject
   389  	Milestones      []RepoMilestone
   390  	Teams           []OrgTeam
   391  }
   392  
   393  func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) {
   394  	var ids []string
   395  	for _, assigneeLogin := range names {
   396  		found := false
   397  		for _, u := range m.AssignableUsers {
   398  			if strings.EqualFold(assigneeLogin, u.Login) {
   399  				ids = append(ids, u.ID)
   400  				found = true
   401  				break
   402  			}
   403  		}
   404  		if !found {
   405  			return nil, fmt.Errorf("'%s' not found", assigneeLogin)
   406  		}
   407  	}
   408  	return ids, nil
   409  }
   410  
   411  func (m *RepoMetadataResult) TeamsToIDs(names []string) ([]string, error) {
   412  	var ids []string
   413  	for _, teamSlug := range names {
   414  		found := false
   415  		slug := teamSlug[strings.IndexRune(teamSlug, '/')+1:]
   416  		for _, t := range m.Teams {
   417  			if strings.EqualFold(slug, t.Slug) {
   418  				ids = append(ids, t.ID)
   419  				found = true
   420  				break
   421  			}
   422  		}
   423  		if !found {
   424  			return nil, fmt.Errorf("'%s' not found", teamSlug)
   425  		}
   426  	}
   427  	return ids, nil
   428  }
   429  
   430  func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) {
   431  	var ids []string
   432  	for _, labelName := range names {
   433  		found := false
   434  		for _, l := range m.Labels {
   435  			if strings.EqualFold(labelName, l.Name) {
   436  				ids = append(ids, l.ID)
   437  				found = true
   438  				break
   439  			}
   440  		}
   441  		if !found {
   442  			return nil, fmt.Errorf("'%s' not found", labelName)
   443  		}
   444  	}
   445  	return ids, nil
   446  }
   447  
   448  func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) {
   449  	var ids []string
   450  	for _, projectName := range names {
   451  		found := false
   452  		for _, p := range m.Projects {
   453  			if strings.EqualFold(projectName, p.Name) {
   454  				ids = append(ids, p.ID)
   455  				found = true
   456  				break
   457  			}
   458  		}
   459  		if !found {
   460  			return nil, fmt.Errorf("'%s' not found", projectName)
   461  		}
   462  	}
   463  	return ids, nil
   464  }
   465  
   466  func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) {
   467  	var paths []string
   468  	for _, projectName := range names {
   469  		found := false
   470  		for _, p := range projects {
   471  			if strings.EqualFold(projectName, p.Name) {
   472  				// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER
   473  				// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER
   474  				var path string
   475  				pathParts := strings.Split(p.ResourcePath, "/")
   476  				if pathParts[1] == "orgs" {
   477  					path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
   478  				} else {
   479  					path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
   480  				}
   481  				paths = append(paths, path)
   482  				found = true
   483  				break
   484  			}
   485  		}
   486  		if !found {
   487  			return nil, fmt.Errorf("'%s' not found", projectName)
   488  		}
   489  	}
   490  	return paths, nil
   491  }
   492  
   493  func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
   494  	for _, m := range m.Milestones {
   495  		if strings.EqualFold(title, m.Title) {
   496  			return m.ID, nil
   497  		}
   498  	}
   499  	return "", fmt.Errorf("'%s' not found", title)
   500  }
   501  
   502  func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) {
   503  	if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 {
   504  		m.AssignableUsers = m2.AssignableUsers
   505  	}
   506  
   507  	if len(m2.Teams) > 0 || len(m.Teams) == 0 {
   508  		m.Teams = m2.Teams
   509  	}
   510  
   511  	if len(m2.Labels) > 0 || len(m.Labels) == 0 {
   512  		m.Labels = m2.Labels
   513  	}
   514  
   515  	if len(m2.Projects) > 0 || len(m.Projects) == 0 {
   516  		m.Projects = m2.Projects
   517  	}
   518  
   519  	if len(m2.Milestones) > 0 || len(m.Milestones) == 0 {
   520  		m.Milestones = m2.Milestones
   521  	}
   522  }
   523  
   524  type RepoMetadataInput struct {
   525  	Assignees  bool
   526  	Reviewers  bool
   527  	Labels     bool
   528  	Projects   bool
   529  	Milestones bool
   530  }
   531  
   532  // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests
   533  func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) {
   534  	result := RepoMetadataResult{}
   535  	errc := make(chan error)
   536  	count := 0
   537  
   538  	if input.Assignees || input.Reviewers {
   539  		count++
   540  		go func() {
   541  			users, err := RepoAssignableUsers(client, repo)
   542  			if err != nil {
   543  				err = fmt.Errorf("error fetching assignees: %w", err)
   544  			}
   545  			result.AssignableUsers = users
   546  			errc <- err
   547  		}()
   548  	}
   549  	if input.Reviewers {
   550  		count++
   551  		go func() {
   552  			teams, err := OrganizationTeams(client, repo)
   553  			// TODO: better detection of non-org repos
   554  			if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
   555  				errc <- fmt.Errorf("error fetching organization teams: %w", err)
   556  				return
   557  			}
   558  			result.Teams = teams
   559  			errc <- nil
   560  		}()
   561  	}
   562  	if input.Labels {
   563  		count++
   564  		go func() {
   565  			labels, err := RepoLabels(client, repo)
   566  			if err != nil {
   567  				err = fmt.Errorf("error fetching labels: %w", err)
   568  			}
   569  			result.Labels = labels
   570  			errc <- err
   571  		}()
   572  	}
   573  	if input.Projects {
   574  		count++
   575  		go func() {
   576  			projects, err := RepoAndOrgProjects(client, repo)
   577  			if err != nil {
   578  				errc <- err
   579  				return
   580  			}
   581  			result.Projects = projects
   582  			errc <- nil
   583  		}()
   584  	}
   585  	if input.Milestones {
   586  		count++
   587  		go func() {
   588  			milestones, err := RepoMilestones(client, repo, "open")
   589  			if err != nil {
   590  				err = fmt.Errorf("error fetching milestones: %w", err)
   591  			}
   592  			result.Milestones = milestones
   593  			errc <- err
   594  		}()
   595  	}
   596  
   597  	var err error
   598  	for i := 0; i < count; i++ {
   599  		if e := <-errc; e != nil {
   600  			err = e
   601  		}
   602  	}
   603  
   604  	return &result, err
   605  }
   606  
   607  type RepoResolveInput struct {
   608  	Assignees  []string
   609  	Reviewers  []string
   610  	Labels     []string
   611  	Projects   []string
   612  	Milestones []string
   613  }
   614  
   615  // RepoResolveMetadataIDs looks up GraphQL node IDs in bulk
   616  func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput) (*RepoMetadataResult, error) {
   617  	users := input.Assignees
   618  	hasUser := func(target string) bool {
   619  		for _, u := range users {
   620  			if strings.EqualFold(u, target) {
   621  				return true
   622  			}
   623  		}
   624  		return false
   625  	}
   626  
   627  	var teams []string
   628  	for _, r := range input.Reviewers {
   629  		if i := strings.IndexRune(r, '/'); i > -1 {
   630  			teams = append(teams, r[i+1:])
   631  		} else if !hasUser(r) {
   632  			users = append(users, r)
   633  		}
   634  	}
   635  
   636  	// there is no way to look up projects nor milestones by name, so preload them all
   637  	mi := RepoMetadataInput{
   638  		Projects:   len(input.Projects) > 0,
   639  		Milestones: len(input.Milestones) > 0,
   640  	}
   641  	result, err := RepoMetadata(client, repo, mi)
   642  	if err != nil {
   643  		return result, err
   644  	}
   645  	if len(users) == 0 && len(teams) == 0 && len(input.Labels) == 0 {
   646  		return result, nil
   647  	}
   648  
   649  	query := &bytes.Buffer{}
   650  	fmt.Fprint(query, "query RepositoryResolveMetadataIDs {\n")
   651  	for i, u := range users {
   652  		fmt.Fprintf(query, "u%03d: user(login:%q){id,login}\n", i, u)
   653  	}
   654  	if len(input.Labels) > 0 {
   655  		fmt.Fprintf(query, "repository(owner:%q,name:%q){\n", repo.RepoOwner(), repo.RepoName())
   656  		for i, l := range input.Labels {
   657  			fmt.Fprintf(query, "l%03d: label(name:%q){id,name}\n", i, l)
   658  		}
   659  		fmt.Fprint(query, "}\n")
   660  	}
   661  	if len(teams) > 0 {
   662  		fmt.Fprintf(query, "organization(login:%q){\n", repo.RepoOwner())
   663  		for i, t := range teams {
   664  			fmt.Fprintf(query, "t%03d: team(slug:%q){id,slug}\n", i, t)
   665  		}
   666  		fmt.Fprint(query, "}\n")
   667  	}
   668  	fmt.Fprint(query, "}\n")
   669  
   670  	response := make(map[string]json.RawMessage)
   671  	err = client.GraphQL(repo.RepoHost(), query.String(), nil, &response)
   672  	if err != nil {
   673  		return result, err
   674  	}
   675  
   676  	for key, v := range response {
   677  		switch key {
   678  		case "repository":
   679  			repoResponse := make(map[string]RepoLabel)
   680  			err := json.Unmarshal(v, &repoResponse)
   681  			if err != nil {
   682  				return result, err
   683  			}
   684  			for _, l := range repoResponse {
   685  				result.Labels = append(result.Labels, l)
   686  			}
   687  		case "organization":
   688  			orgResponse := make(map[string]OrgTeam)
   689  			err := json.Unmarshal(v, &orgResponse)
   690  			if err != nil {
   691  				return result, err
   692  			}
   693  			for _, t := range orgResponse {
   694  				result.Teams = append(result.Teams, t)
   695  			}
   696  		default:
   697  			user := RepoAssignee{}
   698  			err := json.Unmarshal(v, &user)
   699  			if err != nil {
   700  				return result, err
   701  			}
   702  			result.AssignableUsers = append(result.AssignableUsers, user)
   703  		}
   704  	}
   705  
   706  	return result, nil
   707  }
   708  
   709  type RepoProject struct {
   710  	ID           string
   711  	Name         string
   712  	ResourcePath string
   713  }
   714  
   715  // RepoProjects fetches all open projects for a repository
   716  func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
   717  	type responseData struct {
   718  		Repository struct {
   719  			Projects struct {
   720  				Nodes    []RepoProject
   721  				PageInfo struct {
   722  					HasNextPage bool
   723  					EndCursor   string
   724  				}
   725  			} `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
   726  		} `graphql:"repository(owner: $owner, name: $name)"`
   727  	}
   728  
   729  	variables := map[string]interface{}{
   730  		"owner":     githubv4.String(repo.RepoOwner()),
   731  		"name":      githubv4.String(repo.RepoName()),
   732  		"endCursor": (*githubv4.String)(nil),
   733  	}
   734  
   735  	gql := graphQLClient(client.http, repo.RepoHost())
   736  
   737  	var projects []RepoProject
   738  	for {
   739  		var query responseData
   740  		err := gql.QueryNamed(context.Background(), "RepositoryProjectList", &query, variables)
   741  		if err != nil {
   742  			return nil, err
   743  		}
   744  
   745  		projects = append(projects, query.Repository.Projects.Nodes...)
   746  		if !query.Repository.Projects.PageInfo.HasNextPage {
   747  			break
   748  		}
   749  		variables["endCursor"] = githubv4.String(query.Repository.Projects.PageInfo.EndCursor)
   750  	}
   751  
   752  	return projects, nil
   753  }
   754  
   755  // RepoAndOrgProjects fetches all open projects for a repository and its org
   756  func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
   757  	projects, err := RepoProjects(client, repo)
   758  	if err != nil {
   759  		return projects, fmt.Errorf("error fetching projects: %w", err)
   760  	}
   761  
   762  	orgProjects, err := OrganizationProjects(client, repo)
   763  	// TODO: better detection of non-org repos
   764  	if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
   765  		return projects, fmt.Errorf("error fetching organization projects: %w", err)
   766  	}
   767  	projects = append(projects, orgProjects...)
   768  
   769  	return projects, nil
   770  }
   771  
   772  type RepoAssignee struct {
   773  	ID    string
   774  	Login string
   775  }
   776  
   777  // RepoAssignableUsers fetches all the assignable users for a repository
   778  func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) {
   779  	type responseData struct {
   780  		Repository struct {
   781  			AssignableUsers struct {
   782  				Nodes    []RepoAssignee
   783  				PageInfo struct {
   784  					HasNextPage bool
   785  					EndCursor   string
   786  				}
   787  			} `graphql:"assignableUsers(first: 100, after: $endCursor)"`
   788  		} `graphql:"repository(owner: $owner, name: $name)"`
   789  	}
   790  
   791  	variables := map[string]interface{}{
   792  		"owner":     githubv4.String(repo.RepoOwner()),
   793  		"name":      githubv4.String(repo.RepoName()),
   794  		"endCursor": (*githubv4.String)(nil),
   795  	}
   796  
   797  	gql := graphQLClient(client.http, repo.RepoHost())
   798  
   799  	var users []RepoAssignee
   800  	for {
   801  		var query responseData
   802  		err := gql.QueryNamed(context.Background(), "RepositoryAssignableUsers", &query, variables)
   803  		if err != nil {
   804  			return nil, err
   805  		}
   806  
   807  		users = append(users, query.Repository.AssignableUsers.Nodes...)
   808  		if !query.Repository.AssignableUsers.PageInfo.HasNextPage {
   809  			break
   810  		}
   811  		variables["endCursor"] = githubv4.String(query.Repository.AssignableUsers.PageInfo.EndCursor)
   812  	}
   813  
   814  	return users, nil
   815  }
   816  
   817  type RepoLabel struct {
   818  	ID   string
   819  	Name string
   820  }
   821  
   822  // RepoLabels fetches all the labels in a repository
   823  func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
   824  	type responseData struct {
   825  		Repository struct {
   826  			Labels struct {
   827  				Nodes    []RepoLabel
   828  				PageInfo struct {
   829  					HasNextPage bool
   830  					EndCursor   string
   831  				}
   832  			} `graphql:"labels(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
   833  		} `graphql:"repository(owner: $owner, name: $name)"`
   834  	}
   835  
   836  	variables := map[string]interface{}{
   837  		"owner":     githubv4.String(repo.RepoOwner()),
   838  		"name":      githubv4.String(repo.RepoName()),
   839  		"endCursor": (*githubv4.String)(nil),
   840  	}
   841  
   842  	gql := graphQLClient(client.http, repo.RepoHost())
   843  
   844  	var labels []RepoLabel
   845  	for {
   846  		var query responseData
   847  		err := gql.QueryNamed(context.Background(), "RepositoryLabelList", &query, variables)
   848  		if err != nil {
   849  			return nil, err
   850  		}
   851  
   852  		labels = append(labels, query.Repository.Labels.Nodes...)
   853  		if !query.Repository.Labels.PageInfo.HasNextPage {
   854  			break
   855  		}
   856  		variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor)
   857  	}
   858  
   859  	return labels, nil
   860  }
   861  
   862  type RepoMilestone struct {
   863  	ID    string
   864  	Title string
   865  }
   866  
   867  // RepoMilestones fetches milestones in a repository
   868  func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]RepoMilestone, error) {
   869  	type responseData struct {
   870  		Repository struct {
   871  			Milestones struct {
   872  				Nodes    []RepoMilestone
   873  				PageInfo struct {
   874  					HasNextPage bool
   875  					EndCursor   string
   876  				}
   877  			} `graphql:"milestones(states: $states, first: 100, after: $endCursor)"`
   878  		} `graphql:"repository(owner: $owner, name: $name)"`
   879  	}
   880  
   881  	var states []githubv4.MilestoneState
   882  	switch state {
   883  	case "open":
   884  		states = []githubv4.MilestoneState{"OPEN"}
   885  	case "closed":
   886  		states = []githubv4.MilestoneState{"CLOSED"}
   887  	case "all":
   888  		states = []githubv4.MilestoneState{"OPEN", "CLOSED"}
   889  	default:
   890  		return nil, fmt.Errorf("invalid state: %s", state)
   891  	}
   892  
   893  	variables := map[string]interface{}{
   894  		"owner":     githubv4.String(repo.RepoOwner()),
   895  		"name":      githubv4.String(repo.RepoName()),
   896  		"states":    states,
   897  		"endCursor": (*githubv4.String)(nil),
   898  	}
   899  
   900  	gql := graphQLClient(client.http, repo.RepoHost())
   901  
   902  	var milestones []RepoMilestone
   903  	for {
   904  		var query responseData
   905  		err := gql.QueryNamed(context.Background(), "RepositoryMilestoneList", &query, variables)
   906  		if err != nil {
   907  			return nil, err
   908  		}
   909  
   910  		milestones = append(milestones, query.Repository.Milestones.Nodes...)
   911  		if !query.Repository.Milestones.PageInfo.HasNextPage {
   912  			break
   913  		}
   914  		variables["endCursor"] = githubv4.String(query.Repository.Milestones.PageInfo.EndCursor)
   915  	}
   916  
   917  	return milestones, nil
   918  }
   919  
   920  func MilestoneByTitle(client *Client, repo ghrepo.Interface, state, title string) (*RepoMilestone, error) {
   921  	milestones, err := RepoMilestones(client, repo, state)
   922  	if err != nil {
   923  		return nil, err
   924  	}
   925  
   926  	for i := range milestones {
   927  		if strings.EqualFold(milestones[i].Title, title) {
   928  			return &milestones[i], nil
   929  		}
   930  	}
   931  	return nil, fmt.Errorf("no milestone found with title %q", title)
   932  }
   933  
   934  func MilestoneByNumber(client *Client, repo ghrepo.Interface, number int32) (*RepoMilestone, error) {
   935  	var query struct {
   936  		Repository struct {
   937  			Milestone *RepoMilestone `graphql:"milestone(number: $number)"`
   938  		} `graphql:"repository(owner: $owner, name: $name)"`
   939  	}
   940  
   941  	variables := map[string]interface{}{
   942  		"owner":  githubv4.String(repo.RepoOwner()),
   943  		"name":   githubv4.String(repo.RepoName()),
   944  		"number": githubv4.Int(number),
   945  	}
   946  
   947  	gql := graphQLClient(client.http, repo.RepoHost())
   948  
   949  	err := gql.QueryNamed(context.Background(), "RepositoryMilestoneByNumber", &query, variables)
   950  	if err != nil {
   951  		return nil, err
   952  	}
   953  	if query.Repository.Milestone == nil {
   954  		return nil, fmt.Errorf("no milestone found with number '%d'", number)
   955  	}
   956  
   957  	return query.Repository.Milestone, nil
   958  }
   959  
   960  func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
   961  	var paths []string
   962  	projects, err := RepoAndOrgProjects(client, repo)
   963  	if err != nil {
   964  		return paths, err
   965  	}
   966  	return ProjectsToPaths(projects, projectNames)
   967  }