Add repo view --json export functionality

This commit is contained in:
Mislav Marohnić 2021-05-11 20:00:36 +02:00
parent 5f0301c990
commit 02a2ed2f73
10 changed files with 447 additions and 46 deletions

View file

@ -13,7 +13,7 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
switch f {
case "milestone":
if issue.Milestone.Title != "" {
data[f] = &issue.Milestone
data[f] = map[string]string{"title": issue.Milestone.Title}
} else {
data[f] = nil
}
@ -44,7 +44,7 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
data[f] = map[string]string{"name": pr.HeadRepository.Name}
case "milestone":
if pr.Milestone.Title != "" {
data[f] = &pr.Milestone
data[f] = map[string]string{"title": pr.Milestone.Title}
} else {
data[f] = nil
}

53
api/export_repo.go Normal file
View file

@ -0,0 +1,53 @@
package api
import (
"reflect"
)
func (repo *Repository) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(repo).Elem()
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "parent":
data[f] = miniRepoExport(repo.Parent)
case "templateRepository":
data[f] = miniRepoExport(repo.TemplateRepository)
case "languages":
data[f] = repo.Languages.Edges
case "labels":
data[f] = repo.Labels.Nodes
case "assignableUsers":
data[f] = repo.AssignableUsers.Nodes
case "mentionableUsers":
data[f] = repo.MentionableUsers.Nodes
case "milestones":
data[f] = repo.Milestones.Nodes
case "projects":
data[f] = repo.Projects.Nodes
case "repositoryTopics":
var topics []RepositoryTopic
for _, n := range repo.RepositoryTopics.Nodes {
topics = append(topics, n.Topic)
}
data[f] = topics
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return &data
}
func miniRepoExport(r *Repository) map[string]interface{} {
if r == nil {
return nil
}
return map[string]interface{}{
"id": r.ID,
"name": r.Name,
"owner": r.Owner,
}
}

View file

@ -91,7 +91,10 @@ func (p ProjectCards) ProjectNames() []string {
}
type Milestone struct {
Title string `json:"title"`
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
DueOn *time.Time `json:"dueOn"`
}
type IssuesDisabledError struct {

View file

@ -16,25 +16,100 @@ import (
// Repository contains information about a GitHub repo
type Repository struct {
ID string
Name string
Description string
URL string
CloneURL string
CreatedAt time.Time
Owner RepositoryOwner
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
IsPrivate bool
HasIssuesEnabled bool
HasWikiEnabled bool
ViewerPermission string
DefaultBranchRef BranchRef
CreatedAt time.Time
PushedAt *time.Time
UpdatedAt time.Time
Parent *Repository
IsBlankIssuesEnabled bool
IsSecurityPolicyEnabled bool
HasIssuesEnabled bool
HasProjectsEnabled bool
HasWikiEnabled bool
MergeCommitAllowed bool
SquashMergeAllowed bool
RebaseMergeAllowed bool
MergeCommitAllowed bool
RebaseMergeAllowed bool
SquashMergeAllowed 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
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
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
}
// pseudo-field that keeps track of host name of this repo
hostname string
@ -42,12 +117,76 @@ type Repository struct {
// RepositoryOwner is the owner of a GitHub repository
type RepositoryOwner struct {
Login string
ID string `json:"id"`
Login string `json:"login"`
}
type GitHubUser struct {
ID string `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
}
// BranchRef is the branch name in a GitHub repository
type BranchRef struct {
Name string
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"`
}
// RepoOwner is the login name of the owner
@ -65,11 +204,6 @@ func (r Repository) RepoHost() string {
return r.hostname
}
// IsFork is true when this repository has a parent repository
func (r Repository) IsFork() bool {
return r.Parent != nil
}
// ViewerCanPush is true when the requesting user has push access
func (r Repository) ViewerCanPush() bool {
switch r.ViewerPermission {
@ -305,7 +439,6 @@ type repositoryV3 struct {
NodeID string
Name string
CreatedAt time.Time `json:"created_at"`
CloneURL string `json:"clone_url"`
Owner struct {
Login string
}
@ -324,7 +457,6 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
return &Repository{
ID: result.NodeID,
Name: result.Name,
CloneURL: result.CloneURL,
CreatedAt: result.CreatedAt,
Owner: RepositoryOwner{
Login: result.Owner.Login,
@ -707,9 +839,10 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
}
type RepoProject struct {
ID string
Name string
ResourcePath string
ID string `json:"id"`
Name string `json:"name"`
Number int `json:"number"`
ResourcePath string `json:"resourcePath"`
}
// RepoProjects fetches all open projects for a repository

View file

@ -144,9 +144,9 @@ func Test_RepoMetadata(t *testing.T) {
func Test_ProjectsToPaths(t *testing.T) {
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"}
projects := []RepoProject{
{"id1", "My Project", "/OWNER/REPO/projects/PROJECT_NUMBER"},
{"id2", "Org Project", "/orgs/ORG/projects/PROJECT_NUMBER"},
{"id3", "Project", "/orgs/ORG/projects/PROJECT_NUMBER_2"},
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"},
{ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"},
}
projectNames := []string{"My Project", "Org Project"}

View file

@ -186,3 +186,133 @@ func PullRequestGraphQL(fields []string) string {
}
return strings.Join(q, ",")
}
var RepositoryFields = []string{
"id",
"name",
"nameWithOwner",
"owner",
"parent",
"templateRepository",
"description",
"homepageUrl",
"openGraphImageUrl",
"usesCustomOpenGraphImage",
"url",
"sshUrl",
"mirrorUrl",
"securityPolicyUrl",
"createdAt",
"pushedAt",
"updatedAt",
"isBlankIssuesEnabled",
"isSecurityPolicyEnabled",
"hasIssuesEnabled",
"hasProjectsEnabled",
"hasWikiEnabled",
"mergeCommitAllowed",
"squashMergeAllowed",
"rebaseMergeAllowed",
"forkCount",
"stargazerCount",
"watchers",
"issues",
"pullRequests",
"codeOfConduct",
"contactLinks",
"defaultBranchRef",
"deleteBranchOnMerge",
"diskUsage",
"fundingLinks",
"isArchived",
"isEmpty",
"isFork",
"isInOrganization",
"isMirror",
"isPrivate",
"isTemplate",
"isUserConfigurationRepository",
"licenseInfo",
"viewerCanAdminister",
"viewerDefaultCommitEmail",
"viewerDefaultMergeMethod",
"viewerHasStarred",
"viewerPermission",
"viewerPossibleCommitEmails",
"viewerSubscription",
"repositoryTopics",
"primaryLanguage",
"languages",
"issueTemplates",
"pullRequestTemplates",
"labels",
"milestones",
"latestRelease",
"assignableUsers",
"mentionableUsers",
"projects",
// "branchProtectionRules", // too complex to expose
// "collaborators", // does it make sense to expose without affiliation filter?
}
func RepositoryGraphQL(fields []string) string {
var q []string
for _, field := range fields {
switch field {
case "codeOfConduct":
q = append(q, "codeOfConduct{key,name,url}")
case "contactLinks":
q = append(q, "contactLinks{about,name,url}")
case "fundingLinks":
q = append(q, "fundingLinks{platform,url}")
case "licenseInfo":
q = append(q, "licenseInfo{key,name,nickname}")
case "owner":
q = append(q, "owner{id,login}")
case "parent":
q = append(q, "parent{id,name,owner{id,login}}")
case "templateRepository":
q = append(q, "templateRepository{id,name,owner{id,login}}")
case "repositoryTopics":
q = append(q, "repositoryTopics(first:100){nodes{topic{name}}}")
case "issueTemplates":
q = append(q, "issueTemplates{name,title,body,about}")
case "pullRequestTemplates":
q = append(q, "pullRequestTemplates{body,filename}")
case "labels":
q = append(q, "labels(first:100){nodes{id,color,name,description}}")
case "languages":
q = append(q, "languages(first:100){edges{size,node{name}}}")
case "primaryLanguage":
q = append(q, "primaryLanguage{name}")
case "latestRelease":
q = append(q, "latestRelease{publishedAt,tagName,name,url}")
case "milestones":
q = append(q, "milestones(first:100,states:OPEN){nodes{number,title,description,dueOn}}")
case "assignableUsers":
q = append(q, "assignableUsers(first:100){nodes{id,login,name}}")
case "mentionableUsers":
q = append(q, "mentionableUsers(first:100){nodes{id,login,name}}")
case "projects":
q = append(q, "projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}")
case "watchers":
q = append(q, "watchers{totalCount}")
case "issues":
q = append(q, "issues(states:OPEN){totalCount}")
case "pullRequests":
q = append(q, "pullRequests(states:OPEN){totalCount}")
case "defaultBranchRef":
q = append(q, "defaultBranchRef{name}")
default:
q = append(q, field)
}
}
return strings.Join(q, ",")
}

View file

@ -104,7 +104,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
if repo == nil {
continue
}
if repo.IsFork() {
if repo.Parent != nil {
add(repo.Parent)
}
add(repo)

View file

@ -12,6 +12,25 @@ import (
var NotFoundError = errors.New("not found")
func fetchRepository(apiClient *api.Client, repo ghrepo.Interface, fields []string) (*api.Repository, error) {
query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {%s}
}`, api.RepositoryGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository api.Repository
}
if err := apiClient.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
return api.InitRepoHostname(&result.Repository, repo.RepoHost()), nil
}
type RepoReadme struct {
Filename string
Content string

View file

@ -29,6 +29,7 @@ type ViewOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser
Exporter cmdutil.Exporter
RepoArg string
Web bool
@ -67,10 +68,13 @@ With '--branch', view a specific branch of the repository.`,
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a repository in the browser")
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"name", "owner", "description"}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -101,11 +105,24 @@ func viewRun(opts *ViewOptions) error {
}
}
repo, err := api.GitHubRepo(apiClient, toView)
var readme *RepoReadme
fields := defaultFields
if opts.Exporter != nil {
fields = opts.Exporter.Fields()
}
repo, err := fetchRepository(apiClient, toView, fields)
if err != nil {
return err
}
if !opts.Web && opts.Exporter == nil {
readme, err = RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && !errors.Is(err, NotFoundError) {
return err
}
}
openURL := generateBranchURL(toView, opts.Branch)
if opts.Web {
if opts.IO.IsStdoutTTY() {
@ -114,21 +131,17 @@ func viewRun(opts *ViewOptions) error {
return opts.Browser.Browse(openURL)
}
fullName := ghrepo.FullName(toView)
readme, err := RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && err != NotFoundError {
return err
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
if err != nil {
if err := opts.IO.StartPager(); err != nil {
return err
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
}
fullName := ghrepo.FullName(toView)
stdout := opts.IO.Out
if !opts.IO.IsStdoutTTY() {

View file

@ -3,10 +3,12 @@ package view
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
@ -625,3 +627,51 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) {
})
}
}
func Test_viewRun_json(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(false)
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
opts := &ViewOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Exporter: &testExporter{
fields: []string{"name", "defaultBranchRef"},
},
}
_, teardown := run.Stub()
defer teardown(t)
err := viewRun(opts)
assert.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
name: REPO
defaultBranchRef: main
`), stdout.String())
assert.Equal(t, "", stderr.String())
}
type testExporter struct {
fields []string
}
func (e *testExporter) Fields() []string {
return e.fields
}
func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
r := data.(*api.Repository)
fmt.Fprintf(w, "name: %s\n", r.Name)
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
return nil
}