1736 lines
45 KiB
Go
1736 lines
45 KiB
Go
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
|
|
}
|