package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "sort" "strings" "time" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "golang.org/x/sync/errgroup" "github.com/cli/cli/v2/internal/ghrepo" ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/shurcooL/githubv4" ) const ( errorResolvingOrganization = "Could not resolve to an Organization" ) // Repository contains information about a GitHub repo type Repository struct { ID string Name string NameWithOwner string Owner RepositoryOwner Parent *Repository TemplateRepository *Repository Description string HomepageURL string OpenGraphImageURL string UsesCustomOpenGraphImage bool URL string SSHURL string MirrorURL string SecurityPolicyURL string CreatedAt time.Time PushedAt *time.Time UpdatedAt time.Time ArchivedAt *time.Time IsBlankIssuesEnabled bool IsSecurityPolicyEnabled bool HasIssuesEnabled bool HasProjectsEnabled bool HasDiscussionsEnabled bool HasWikiEnabled bool MergeCommitAllowed bool SquashMergeAllowed bool RebaseMergeAllowed bool AutoMergeAllowed bool ForkCount int StargazerCount int Watchers struct { TotalCount int `json:"totalCount"` } Issues struct { TotalCount int `json:"totalCount"` } PullRequests struct { TotalCount int `json:"totalCount"` } CodeOfConduct *CodeOfConduct ContactLinks []ContactLink DefaultBranchRef BranchRef DeleteBranchOnMerge bool DiskUsage int FundingLinks []FundingLink IsArchived bool IsEmpty bool IsFork bool ForkingAllowed bool IsInOrganization bool IsMirror bool IsPrivate bool IsTemplate bool IsUserConfigurationRepository bool LicenseInfo *RepositoryLicense ViewerCanAdminister bool ViewerDefaultCommitEmail string ViewerDefaultMergeMethod string ViewerHasStarred bool ViewerPermission string ViewerPossibleCommitEmails []string ViewerSubscription string Visibility string RepositoryTopics struct { Nodes []struct { Topic RepositoryTopic } } PrimaryLanguage *CodingLanguage Languages struct { Edges []struct { Size int `json:"size"` Node CodingLanguage `json:"node"` } } IssueTemplates []IssueTemplate PullRequestTemplates []PullRequestTemplate Labels struct { Nodes []IssueLabel } Milestones struct { Nodes []Milestone } LatestRelease *RepositoryRelease AssignableUsers struct { Nodes []GitHubUser } MentionableUsers struct { Nodes []GitHubUser } Projects struct { Nodes []RepoProject } ProjectsV2 struct { Nodes []ProjectV2 } // pseudo-field that keeps track of host name of this repo hostname string } // RepositoryOwner is the owner of a GitHub repository type RepositoryOwner struct { ID string `json:"id"` Login string `json:"login"` } type GitHubUser struct { ID string `json:"id"` Login string `json:"login"` Name string `json:"name"` DatabaseID int64 `json:"databaseId"` } // DisplayName returns a user-friendly name via actorDisplayName. func (u GitHubUser) DisplayName() string { return actorDisplayName("", u.Login, u.Name) } // Actor is a superset of User and Bot, among others. // At the time of writing, some of these fields // are not directly supported by the Actor type and // instead are only available on the User or Bot types // directly. type Actor struct { ID string `json:"id"` Login string `json:"login"` Name string `json:"name"` TypeName string `json:"__typename"` } // BranchRef is the branch name in a GitHub repository type BranchRef struct { Name string `json:"name"` } type CodeOfConduct struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` } type RepositoryLicense struct { Key string `json:"key"` Name string `json:"name"` Nickname string `json:"nickname"` } type ContactLink struct { About string `json:"about"` Name string `json:"name"` URL string `json:"url"` } type FundingLink struct { Platform string `json:"platform"` URL string `json:"url"` } type CodingLanguage struct { Name string `json:"name"` } type IssueTemplate struct { Name string `json:"name"` Title string `json:"title"` Body string `json:"body"` About string `json:"about"` } type PullRequestTemplate struct { Filename string `json:"filename"` Body string `json:"body"` } type RepositoryTopic struct { Name string `json:"name"` } type RepositoryRelease struct { Name string `json:"name"` TagName string `json:"tagName"` URL string `json:"url"` PublishedAt time.Time `json:"publishedAt"` } type IssueLabel struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` } type License struct { Key string `json:"key"` Name string `json:"name"` SPDXID string `json:"spdx_id"` URL string `json:"url"` NodeID string `json:"node_id"` HTMLURL string `json:"html_url"` Description string `json:"description"` Implementation string `json:"implementation"` Permissions []string `json:"permissions"` Conditions []string `json:"conditions"` Limitations []string `json:"limitations"` Body string `json:"body"` Featured bool `json:"featured"` } type GitIgnore struct { Name string `json:"name"` Source string `json:"source"` } // RepoOwner is the login name of the owner func (r Repository) RepoOwner() string { return r.Owner.Login } // RepoName is the name of the repository func (r Repository) RepoName() string { return r.Name } // RepoHost is the GitHub hostname of the repository func (r Repository) RepoHost() string { return r.hostname } // ViewerCanPush is true when the requesting user has push access func (r Repository) ViewerCanPush() bool { switch r.ViewerPermission { case "ADMIN", "MAINTAIN", "WRITE": return true default: return false } } // ViewerCanTriage is true when the requesting user can triage issues and pull requests func (r Repository) ViewerCanTriage() bool { switch r.ViewerPermission { case "ADMIN", "MAINTAIN", "WRITE", "TRIAGE": return true default: return false } } func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*Repository, error) { query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) {%s} }`, RepositoryGraphQL(fields)) variables := map[string]interface{}{ "owner": repo.RepoOwner(), "name": repo.RepoName(), } var result struct { Repository *Repository } if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { return nil, err } // The GraphQL API should have returned an error in case of a missing repository, but this isn't // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ GraphQLError: &ghAPI.GraphQLError{ Errors: []ghAPI.GraphQLErrorItem{{ Type: "NOT_FOUND", Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), }}, }, } } return InitRepoHostname(result.Repository, repo.RepoHost()), nil } // IssueRepoInfo fetches only the repository fields needed for issue operations such as // issue creation and transfer, avoiding fields like defaultBranchRef that require additional // token permissions. func IssueRepoInfo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` query IssueRepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id name owner { login } hasIssuesEnabled viewerPermission } }` variables := map[string]interface{}{ "owner": repo.RepoOwner(), "name": repo.RepoName(), } var result struct { Repository *Repository } if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { return nil, err } // The GraphQL API should have returned an error in case of a missing repository, but this isn't // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ GraphQLError: &ghAPI.GraphQLError{ Errors: []ghAPI.GraphQLErrorItem{{ Type: "NOT_FOUND", Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), }}, }, } } return InitRepoHostname(result.Repository, repo.RepoHost()), nil } func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` fragment repo on Repository { id name owner { login } hasIssuesEnabled description hasWikiEnabled viewerPermission defaultBranchRef { name } } query RepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ...repo parent { ...repo } mergeCommitAllowed rebaseMergeAllowed squashMergeAllowed } }` variables := map[string]interface{}{ "owner": repo.RepoOwner(), "name": repo.RepoName(), } var result struct { Repository *Repository } if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { return nil, err } // The GraphQL API should have returned an error in case of a missing repository, but this isn't // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ GraphQLError: &ghAPI.GraphQLError{ Errors: []ghAPI.GraphQLErrorItem{{ Type: "NOT_FOUND", Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), }}, }, } } return InitRepoHostname(result.Repository, repo.RepoHost()), nil } func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) { if r, ok := repo.(*Repository); ok && r.DefaultBranchRef.Name != "" { return r.DefaultBranchRef.Name, nil } r, err := GitHubRepo(client, repo) if err != nil { return "", err } return r.DefaultBranchRef.Name, nil } func CanPushToRepo(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { if r, ok := repo.(*Repository); ok && r.ViewerPermission != "" { return r.ViewerCanPush(), nil } apiClient := NewClientFromHTTP(httpClient) r, err := GitHubRepo(apiClient, repo) if err != nil { return false, err } return r.ViewerCanPush(), nil } // RepoParent finds out the parent repository of a fork func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) { var query struct { Repository struct { Parent *struct { Name string Owner struct { Login string } } } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), } err := client.Query(repo.RepoHost(), "RepositoryFindParent", &query, variables) if err != nil { return nil, err } if query.Repository.Parent == nil { return nil, nil } parent := ghrepo.NewWithHost(query.Repository.Parent.Owner.Login, query.Repository.Parent.Name, repo.RepoHost()) return parent, nil } // RepoNetworkResult describes the relationship between related repositories type RepoNetworkResult struct { ViewerLogin string Repositories []*Repository } // RepoNetwork inspects the relationship between multiple GitHub repositories func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, error) { var hostname string if len(repos) > 0 { hostname = repos[0].RepoHost() } queries := make([]string, 0, len(repos)) for i, repo := range repos { queries = append(queries, fmt.Sprintf(` repo_%03d: repository(owner: %q, name: %q) { ...repo parent { ...repo } } `, i, repo.RepoOwner(), repo.RepoName())) } // Since the query is constructed dynamically, we can't parse a response // format using a static struct. Instead, hold the raw JSON data until we // decide how to parse it manually. graphqlResult := make(map[string]*json.RawMessage) var result RepoNetworkResult err := client.GraphQL(hostname, fmt.Sprintf(` fragment repo on Repository { id name owner { login } viewerPermission defaultBranchRef { name } isPrivate } query RepositoryNetwork { viewer { login } %s } `, strings.Join(queries, "")), nil, &graphqlResult) var graphqlError GraphQLError if errors.As(err, &graphqlError) { // If the only errors are that certain repositories are not found, // continue processing this response instead of returning an error tolerated := true for _, ge := range graphqlError.Errors { if ge.Type != "NOT_FOUND" { tolerated = false } } if tolerated { err = nil } } if err != nil { return result, err } keys := make([]string, 0, len(graphqlResult)) for key := range graphqlResult { keys = append(keys, key) } // sort keys to ensure `repo_{N}` entries are processed in order sort.Strings(keys) // Iterate over keys of GraphQL response data and, based on its name, // dynamically allocate the target struct an individual message gets decoded to. for _, name := range keys { jsonMessage := graphqlResult[name] if name == "viewer" { viewerResult := struct { Login string }{} decoder := json.NewDecoder(bytes.NewReader([]byte(*jsonMessage))) if err := decoder.Decode(&viewerResult); err != nil { return result, err } result.ViewerLogin = viewerResult.Login } else if strings.HasPrefix(name, "repo_") { if jsonMessage == nil { result.Repositories = append(result.Repositories, nil) continue } var repo Repository decoder := json.NewDecoder(bytes.NewReader(*jsonMessage)) if err := decoder.Decode(&repo); err != nil { return result, err } result.Repositories = append(result.Repositories, InitRepoHostname(&repo, hostname)) } else { return result, fmt.Errorf("unknown GraphQL result key %q", name) } } return result, nil } func InitRepoHostname(repo *Repository, hostname string) *Repository { repo.hostname = hostname if repo.Parent != nil { repo.Parent.hostname = hostname } return repo } // RepositoryV3 is the repository result from GitHub API v3 type repositoryV3 struct { NodeID string `json:"node_id"` Name string CreatedAt time.Time `json:"created_at"` Owner struct { Login string } Private bool HTMLUrl string `json:"html_url"` Parent *repositoryV3 } // ForkRepo forks the repository on GitHub and returns the new repository func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*Repository, error) { path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo)) params := map[string]interface{}{} if org != "" { params["organization"] = org } if newName != "" { params["name"] = newName } if defaultBranchOnly { params["default_branch_only"] = true } body := &bytes.Buffer{} enc := json.NewEncoder(body) if err := enc.Encode(params); err != nil { return nil, err } result := repositoryV3{} err := client.REST(repo.RepoHost(), "POST", path, body, &result) if err != nil { return nil, err } newRepo := &Repository{ ID: result.NodeID, Name: result.Name, CreatedAt: result.CreatedAt, Owner: RepositoryOwner{ Login: result.Owner.Login, }, ViewerPermission: "WRITE", hostname: repo.RepoHost(), } // The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking // actually took place. Ensure that we raise an error instead. if ghrepo.IsSame(repo, newRepo) { return newRepo, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo)) } return newRepo, nil } // RenameRepo renames the repository on GitHub and returns the renamed repository func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) { input := map[string]string{"name": newRepoName} body := &bytes.Buffer{} enc := json.NewEncoder(body) if err := enc.Encode(input); err != nil { return nil, err } path := fmt.Sprintf("%srepos/%s", ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo)) result := repositoryV3{} err := client.REST(repo.RepoHost(), "PATCH", path, body, &result) if err != nil { return nil, err } return &Repository{ ID: result.NodeID, Name: result.Name, CreatedAt: result.CreatedAt, Owner: RepositoryOwner{ Login: result.Owner.Login, }, ViewerPermission: "WRITE", hostname: repo.RepoHost(), }, nil } func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) { var responseData struct { Repository struct { DefaultBranchRef struct { Target struct { Commit `graphql:"... on Commit"` } } } `graphql:"repository(owner: $owner, name: $repo)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()), } if err := client.Query(repo.RepoHost(), "LastCommit", &responseData, variables); err != nil { return nil, err } return &responseData.Repository.DefaultBranchRef.Target.Commit, nil } // RepoFindForks finds forks of the repo that are affiliated with the viewer func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) { result := struct { Repository struct { Forks struct { Nodes []Repository } } }{} variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), "limit": limit, } if err := client.GraphQL(repo.RepoHost(), ` query RepositoryFindFork($owner: String!, $repo: String!, $limit: Int!) { repository(owner: $owner, name: $repo) { forks(first: $limit, affiliations: [OWNER, COLLABORATOR]) { nodes { id name owner { login } url viewerPermission } } } } `, variables, &result); err != nil { return nil, err } var results []*Repository for _, r := range result.Repository.Forks.Nodes { // we check ViewerCanPush, even though we expect it to always be true per // `affiliations` condition, to guard against versions of GitHub with a // faulty `affiliations` implementation if !r.ViewerCanPush() { continue } results = append(results, InitRepoHostname(&r, repo.RepoHost())) } return results, nil } type RepoMetadataResult struct { CurrentLogin string AssignableUsers []AssignableUser AssignableActors []AssignableActor Labels []RepoLabel Projects []RepoProject ProjectsV2 []ProjectV2 Milestones []RepoMilestone Teams []OrgTeam } func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { var ids []string for _, assigneeLogin := range names { found := false for _, u := range m.AssignableUsers { if strings.EqualFold(assigneeLogin, u.Login()) { ids = append(ids, u.ID()) found = true break } } // Look for ID in assignable actors if not found in assignable users if !found { for _, a := range m.AssignableActors { if strings.EqualFold(assigneeLogin, a.Login()) { ids = append(ids, a.ID()) found = true break } if strings.EqualFold(assigneeLogin, a.DisplayName()) { ids = append(ids, a.ID()) found = true break } } } // And if we still didn't find an ID, return an error if !found { return nil, fmt.Errorf("'%s' not found", assigneeLogin) } } return ids, nil } func (m *RepoMetadataResult) TeamsToIDs(names []string) ([]string, error) { var ids []string for _, teamSlug := range names { found := false slug := teamSlug[strings.IndexRune(teamSlug, '/')+1:] for _, t := range m.Teams { if strings.EqualFold(slug, t.Slug) { ids = append(ids, t.ID) found = true break } } if !found { return nil, fmt.Errorf("'%s' not found", teamSlug) } } return ids, nil } func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { var ids []string for _, labelName := range names { found := false for _, l := range m.Labels { if strings.EqualFold(labelName, l.Name) { ids = append(ids, l.ID) found = true break } } if !found { return nil, fmt.Errorf("'%s' not found", labelName) } } return ids, nil } // ProjectsTitlesToIDs returns two arrays: // - the first contains IDs of projects V1 // - the second contains IDs of projects V2 // - if neither project V1 or project V2 can be found with a given name, then an error is returned func (m *RepoMetadataResult) ProjectsTitlesToIDs(titles []string) ([]string, []string, error) { var ids []string var idsV2 []string for _, title := range titles { id, found := m.v1ProjectNameToID(title) if found { ids = append(ids, id) continue } idV2, found := m.v2ProjectTitleToID(title) if found { idsV2 = append(idsV2, idV2) continue } return nil, nil, fmt.Errorf("'%s' not found", title) } return ids, idsV2, nil } // We use the word "titles" when referring to v1 and v2 projects. // In reality, v1 projects really have "names", so there is a bit of a // mismatch we just need to gloss over. func (m *RepoMetadataResult) v1ProjectNameToID(name string) (string, bool) { for _, p := range m.Projects { if strings.EqualFold(name, p.Name) { return p.ID, true } } return "", false } func (m *RepoMetadataResult) v2ProjectTitleToID(title string) (string, bool) { for _, p := range m.ProjectsV2 { if strings.EqualFold(title, p.Title) { return p.ID, true } } return "", false } func ProjectTitlesToPaths(client *Client, repo ghrepo.Interface, titles []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { paths := make([]string, 0, len(titles)) matchedPaths := map[string]struct{}{} // TODO: ProjectsV1Cleanup // At this point, we only know the names that the user has provided, so we can't push this conditional up the stack. // First we'll try to match against v1 projects, if supported if projectsV1Support == gh.ProjectsV1Supported { v1Projects, err := v1Projects(client, repo) if err != nil { return nil, err } for _, title := range titles { for _, p := range v1Projects { if strings.EqualFold(title, p.Name) { pathParts := strings.Split(p.ResourcePath, "/") var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) } else { path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) } paths = append(paths, path) matchedPaths[title] = struct{}{} break } } } } // Then we'll try to match against v2 projects v2Projects, err := v2Projects(client, repo) if err != nil { return nil, err } for _, title := range titles { // If we already found a v1 project with this name, skip it if _, ok := matchedPaths[title]; ok { continue } found := false for _, p := range v2Projects { if strings.EqualFold(title, p.Title) { pathParts := strings.Split(p.ResourcePath, "/") var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) } else { path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) } paths = append(paths, path) found = true break } } if !found { return nil, fmt.Errorf("'%s' not found", title) } } return paths, nil } func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { for _, m := range m.Milestones { if strings.EqualFold(title, m.Title) { return m.ID, nil } } return "", fmt.Errorf("'%s' not found", title) } func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) { if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 { m.AssignableUsers = m2.AssignableUsers } if len(m2.Teams) > 0 || len(m.Teams) == 0 { m.Teams = m2.Teams } if len(m2.Labels) > 0 || len(m.Labels) == 0 { m.Labels = m2.Labels } if len(m2.Projects) > 0 || len(m.Projects) == 0 { m.Projects = m2.Projects } if len(m2.Milestones) > 0 || len(m.Milestones) == 0 { m.Milestones = m2.Milestones } } type RepoMetadataInput struct { Assignees bool Reviewers bool TeamReviewers bool // TODO ApiActorsSupported ApiActorsSupported bool Labels bool ProjectsV1 bool ProjectsV2 bool Milestones bool } // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) { var result RepoMetadataResult var g errgroup.Group if input.Assignees || input.Reviewers { // TODO ApiActorsSupported if input.ApiActorsSupported { g.Go(func() error { actors, err := RepoAssignableActors(client, repo) if err != nil { return fmt.Errorf("error fetching assignable actors: %w", err) } result.AssignableActors = actors // Filter actors for users to use for pull request reviewers, // skip retrieving the same info through RepoAssignableUsers(). var users []AssignableUser for _, a := range actors { if _, ok := a.(AssignableUser); !ok { continue } users = append(users, a.(AssignableUser)) } result.AssignableUsers = users return nil }) } else { // Not using Actors, fetch legacy assignable users. g.Go(func() error { users, err := RepoAssignableUsers(client, repo) if err != nil { err = fmt.Errorf("error fetching assignable users: %w", err) } result.AssignableUsers = users return err }) } } if input.Reviewers && input.TeamReviewers { g.Go(func() error { teams, err := OrganizationTeams(client, repo) // TODO: better detection of non-org repos if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { err = fmt.Errorf("error fetching organization teams: %w", err) return err } result.Teams = teams return nil }) } if input.Reviewers { g.Go(func() error { login, err := CurrentLoginName(client, repo.RepoHost()) if err != nil { err = fmt.Errorf("error fetching current login: %w", err) } result.CurrentLogin = login return err }) } if input.Labels { g.Go(func() error { labels, err := RepoLabels(client, repo) if err != nil { err = fmt.Errorf("error fetching labels: %w", err) } result.Labels = labels return err }) } if input.ProjectsV1 { g.Go(func() error { var err error result.Projects, err = v1Projects(client, repo) return err }) } if input.ProjectsV2 { g.Go(func() error { var err error result.ProjectsV2, err = v2Projects(client, repo) return err }) } if input.Milestones { g.Go(func() error { milestones, err := RepoMilestones(client, repo, "open") if err != nil { err = fmt.Errorf("error fetching milestones: %w", err) } result.Milestones = milestones return err }) } if err := g.Wait(); err != nil { return nil, err } return &result, nil } type RepoProject struct { ID string `json:"id"` Name string `json:"name"` Number int `json:"number"` ResourcePath string `json:"resourcePath"` } // RepoProjects fetches all open projects for a repository. func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { type responseData struct { Repository struct { Projects struct { Nodes []RepoProject PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "endCursor": (*githubv4.String)(nil), } var projects []RepoProject for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryProjectList", &query, variables) if err != nil { return nil, err } projects = append(projects, query.Repository.Projects.Nodes...) if !query.Repository.Projects.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.Projects.PageInfo.EndCursor) } return projects, nil } // Expected login for Copilot when retrieved as an assignee const CopilotAssigneeLogin = "copilot-swe-agent" // Expected login for Copilot when retrieved as a Pull Request Reviewer. const CopilotReviewerLogin = "copilot-pull-request-reviewer" const CopilotActorName = "Copilot" // actorDisplayName returns a user-friendly display name for any actor. // It handles bots (e.g. Copilot → "Copilot (AI)"), users with names // ("login (Name)"), and falls back to just login. Empty typeName is // treated as a possible bot or user — the login is checked against // known bot logins first. func actorDisplayName(typeName, login, name string) string { if login == CopilotReviewerLogin || login == CopilotAssigneeLogin || login == CopilotActorName { return fmt.Sprintf("%s (AI)", CopilotActorName) } if typeName == botTypeName { return login } if name != "" { return fmt.Sprintf("%s (%s)", login, name) } return login } type AssignableActor interface { DisplayName() string ID() string Login() string sealedAssignableActor() } // Always a user type AssignableUser struct { id string login string name string } func NewAssignableUser(id, login, name string) AssignableUser { return AssignableUser{ id: id, login: login, name: name, } } // DisplayName returns a user-friendly name via actorDisplayName. func (u AssignableUser) DisplayName() string { return actorDisplayName(userTypeName, u.login, u.name) } func (u AssignableUser) ID() string { return u.id } func (u AssignableUser) Login() string { return u.login } func (u AssignableUser) Name() string { return u.name } func (u AssignableUser) sealedAssignableActor() {} type AssignableBot struct { id string login string } func NewAssignableBot(id, login string) AssignableBot { return AssignableBot{ id: id, login: login, } } func (b AssignableBot) DisplayName() string { return actorDisplayName(botTypeName, b.login, "") } func (b AssignableBot) ID() string { return b.id } func (b AssignableBot) Login() string { return b.login } func (b AssignableBot) Name() string { return "" } func (b AssignableBot) sealedAssignableActor() {} // RepoAssignableUsers fetches all the assignable users for a repository func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]AssignableUser, error) { type responseData struct { Repository struct { AssignableUsers struct { Nodes []struct { ID string Login string Name string } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"assignableUsers(first: 100, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "endCursor": (*githubv4.String)(nil), } var users []AssignableUser for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables) if err != nil { return nil, err } for _, node := range query.Repository.AssignableUsers.Nodes { user := AssignableUser{ id: node.ID, login: node.Login, name: node.Name, } users = append(users, user) } if !query.Repository.AssignableUsers.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.AssignableUsers.PageInfo.EndCursor) } return users, nil } // RepoAssignableActors fetches all the assignable actors for a repository on // GitHub hosts that support Actor assignees. func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableActor, error) { type responseData struct { Repository struct { SuggestedActors struct { Nodes []struct { User struct { ID string Login string Name string TypeName string `graphql:"__typename"` } `graphql:"... on User"` Bot struct { ID string Login string TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "endCursor": (*githubv4.String)(nil), } var actors []AssignableActor for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryAssignableActors", &query, variables) if err != nil { return nil, err } for _, node := range query.Repository.SuggestedActors.Nodes { if node.User.TypeName == "User" { actor := AssignableUser{ id: node.User.ID, login: node.User.Login, name: node.User.Name, } actors = append(actors, actor) } else if node.Bot.TypeName == "Bot" { actor := AssignableBot{ id: node.Bot.ID, login: node.Bot.Login, } actors = append(actors, actor) } } if !query.Repository.SuggestedActors.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) } return actors, nil } // SearchRepoAssignableActors searches assignable actors for a repository with an optional // query string. Unlike RepoAssignableActors which fetches all actors with pagination, this // returns up to 10 results matching the query, suitable for search-based selection. func SearchRepoAssignableActors(client *Client, repo ghrepo.Interface, query string) ([]AssignableActor, int, error) { type responseData struct { Repository struct { AssignableUsers struct { TotalCount int } SuggestedActors struct { Nodes []struct { User struct { ID string Login string Name string TypeName string `graphql:"__typename"` } `graphql:"... on User"` Bot struct { ID string Login string TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } } `graphql:"suggestedActors(first: 10, query: $query, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` } var q *githubv4.String if query != "" { v := githubv4.String(query) q = &v } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "query": q, } var result responseData if err := client.Query(repo.RepoHost(), "SearchRepoAssignableActors", &result, variables); err != nil { return nil, 0, err } var actors []AssignableActor for _, node := range result.Repository.SuggestedActors.Nodes { if node.User.TypeName == "User" { actors = append(actors, AssignableUser{ id: node.User.ID, login: node.User.Login, name: node.User.Name, }) } else if node.Bot.TypeName == "Bot" { actors = append(actors, AssignableBot{ id: node.Bot.ID, login: node.Bot.Login, }) } } return actors, result.Repository.AssignableUsers.TotalCount, nil } type RepoLabel struct { ID string Name string } // RepoLabels fetches all the labels in a repository func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) { type responseData struct { Repository struct { Labels struct { Nodes []RepoLabel PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"labels(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "endCursor": (*githubv4.String)(nil), } var labels []RepoLabel for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryLabelList", &query, variables) if err != nil { return nil, err } labels = append(labels, query.Repository.Labels.Nodes...) if !query.Repository.Labels.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) } return labels, nil } type RepoMilestone struct { ID string Title string } // RepoMilestones fetches milestones in a repository func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]RepoMilestone, error) { type responseData struct { Repository struct { Milestones struct { Nodes []RepoMilestone PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"milestones(states: $states, first: 100, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } var states []githubv4.MilestoneState switch state { case "open": states = []githubv4.MilestoneState{"OPEN"} case "closed": states = []githubv4.MilestoneState{"CLOSED"} case "all": states = []githubv4.MilestoneState{"OPEN", "CLOSED"} default: return nil, fmt.Errorf("invalid state: %s", state) } variables := map[string]interface{}{ "owner": githubv4.String(repo.RepoOwner()), "name": githubv4.String(repo.RepoName()), "states": states, "endCursor": (*githubv4.String)(nil), } var milestones []RepoMilestone for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryMilestoneList", &query, variables) if err != nil { return nil, err } milestones = append(milestones, query.Repository.Milestones.Nodes...) if !query.Repository.Milestones.PageInfo.HasNextPage { break } variables["endCursor"] = githubv4.String(query.Repository.Milestones.PageInfo.EndCursor) } return milestones, nil } // v1Projects retrieves set of RepoProjects relevant to given repository: // - Projects for repository // - Projects for repository organization, if it belongs to one func v1Projects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { var repoProjects []RepoProject var orgProjects []RepoProject g, _ := errgroup.WithContext(context.Background()) g.Go(func() error { var err error repoProjects, err = RepoProjects(client, repo) if err != nil { err = fmt.Errorf("error fetching repo projects (classic): %w", err) } return err }) g.Go(func() error { var err error orgProjects, err = OrganizationProjects(client, repo) if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { err = fmt.Errorf("error fetching organization projects (classic): %w", err) return err } return nil }) if err := g.Wait(); err != nil { return nil, err } projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects)) projects = append(projects, repoProjects...) projects = append(projects, orgProjects...) return projects, nil } // v2Projects retrieves set of ProjectV2 relevant to given repository: // - ProjectsV2 owned by current user // - ProjectsV2 linked to repository // - ProjectsV2 owned by repository organization, if it belongs to one func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { var userProjectsV2 []ProjectV2 var repoProjectsV2 []ProjectV2 var orgProjectsV2 []ProjectV2 g, _ := errgroup.WithContext(context.Background()) g.Go(func() error { var err error userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost()) if err != nil && !ProjectsV2IgnorableError(err) { err = fmt.Errorf("error fetching user projects: %w", err) return err } return nil }) g.Go(func() error { var err error repoProjectsV2, err = RepoProjectsV2(client, repo) if err != nil && !ProjectsV2IgnorableError(err) { err = fmt.Errorf("error fetching repo projects: %w", err) return err } return nil }) g.Go(func() error { var err error orgProjectsV2, err = OrganizationProjectsV2(client, repo) if err != nil && !ProjectsV2IgnorableError(err) && !strings.Contains(err.Error(), errorResolvingOrganization) { err = fmt.Errorf("error fetching organization projects: %w", err) return err } return nil }) if err := g.Wait(); err != nil { return nil, err } // ProjectV2 might appear across multiple queries so use a map to keep them deduplicated. m := make(map[string]ProjectV2, len(userProjectsV2)+len(repoProjectsV2)+len(orgProjectsV2)) for _, p := range userProjectsV2 { m[p.ID] = p } for _, p := range repoProjectsV2 { m[p.ID] = p } for _, p := range orgProjectsV2 { m[p.ID] = p } projectsV2 := make([]ProjectV2, 0, len(m)) for _, p := range m { projectsV2 = append(projectsV2, p) } return projectsV2, nil } func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { var responsev3 repositoryV3 err := apiClient.REST(hostname, method, path, body, &responsev3) if err != nil { return nil, err } return &Repository{ Name: responsev3.Name, CreatedAt: responsev3.CreatedAt, Owner: RepositoryOwner{ Login: responsev3.Owner.Login, }, ID: responsev3.NodeID, hostname: hostname, URL: responsev3.HTMLUrl, IsPrivate: responsev3.Private, }, nil } // MapReposToIDs retrieves a set of IDs for the given set of repositories. // This is similar logic to RepoNetwork, but only fetches databaseId and does not // discover parent repositories. func GetRepoIDs(client *Client, host string, repositories []ghrepo.Interface) ([]int64, error) { queries := make([]string, 0, len(repositories)) for i, repo := range repositories { queries = append(queries, fmt.Sprintf(` repo_%03d: repository(owner: %q, name: %q) { databaseId } `, i, repo.RepoOwner(), repo.RepoName())) } query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, "")) graphqlResult := make(map[string]*struct { DatabaseID int64 `json:"databaseId"` }) if err := client.GraphQL(host, query, nil, &graphqlResult); err != nil { return nil, fmt.Errorf("failed to look up repositories: %w", err) } repoKeys := make([]string, 0, len(repositories)) for k := range graphqlResult { repoKeys = append(repoKeys, k) } sort.Strings(repoKeys) result := make([]int64, len(repositories)) for i, k := range repoKeys { result[i] = graphqlResult[k].DatabaseID } return result, nil } func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) { path := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) resp, err := client.HTTP().Head(path) if err != nil { return false, err } defer resp.Body.Close() switch resp.StatusCode { case 200: return true, nil case 404: return false, nil default: return false, ghAPI.HandleHTTPError(resp) } } // RepoLicenses fetches available repository licenses. // It uses API v3 because licenses are not supported by GraphQL. func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) { var licenses []License client := NewClientFromHTTP(httpClient) err := client.REST(hostname, "GET", "licenses", nil, &licenses) if err != nil { return nil, err } return licenses, nil } // RepoLicense fetches an available repository license. // It uses API v3 because licenses are not supported by GraphQL. func RepoLicense(httpClient *http.Client, hostname string, licenseName string) (*License, error) { var license License client := NewClientFromHTTP(httpClient) path := fmt.Sprintf("licenses/%s", licenseName) err := client.REST(hostname, "GET", path, nil, &license) if err != nil { return nil, err } return &license, nil } // RepoGitIgnoreTemplates fetches available repository gitignore templates. // It uses API v3 here because gitignore template isn't supported by GraphQL. func RepoGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { var gitIgnoreTemplates []string client := NewClientFromHTTP(httpClient) err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates) if err != nil { return nil, err } return gitIgnoreTemplates, nil } // RepoGitIgnoreTemplate fetches an available repository gitignore template. // It uses API v3 here because gitignore template isn't supported by GraphQL. func RepoGitIgnoreTemplate(httpClient *http.Client, hostname string, gitIgnoreTemplateName string) (*GitIgnore, error) { var gitIgnoreTemplate GitIgnore client := NewClientFromHTTP(httpClient) path := fmt.Sprintf("gitignore/templates/%s", gitIgnoreTemplateName) err := client.REST(hostname, "GET", path, nil, &gitIgnoreTemplate) if err != nil { return nil, err } return &gitIgnoreTemplate, nil }