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