github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/api/queries_repo.go (about)

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