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 }