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