github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/api/queries_repo.go (about)

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