Merge remote-tracking branch 'origin' into pr-lookup-refactor

This commit is contained in:
Mislav Marohnić 2021-05-17 17:41:38 +02:00
commit bc3bb97c43
52 changed files with 1128 additions and 385 deletions

View file

@ -36,6 +36,8 @@ Run the new binary as:
Run tests with: `go test ./...`
See [project layout documentation](../project-layout.md) for information on where to find specific source files.
## Submitting a pull request
1. Create a new branch: `git checkout -b my-branch-name`

4
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,4 @@
<!--
Thank you for contributing to GitHub CLI!
To reference an open issue, please write this in your description: `Fixes #NUMBER`
-->

View file

@ -1,19 +0,0 @@
---
name: "\U0001F41B Bug fix"
about: Fix a bug in GitHub CLI
---
<!--
Please make sure you read our contributing guidelines at
https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md
before opening a pull request. Thanks!
-->
## Summary
closes #[issue number]
## Details
-

View file

@ -133,9 +133,11 @@ jobs:
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
run: |
mkdir -p build
msi="$(basename "${{ steps.download_exe.outputs.zip }}" ".zip").msi"
msi="$(basename "$ZIP_FILE" ".zip").msi"
printf "::set-output name=msi::%s\n" "$msi"
go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}"
- name: Obtain signing cert
@ -145,14 +147,24 @@ jobs:
run: .\script\setup-windows-certificate.ps1
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }}
run: |
.\script\sign.ps1 -Certificate "${{ steps.obtain_cert.outputs.cert-file }}" `
-Executable "${{ steps.buildmsi.outputs.msi }}"
run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE
- name: Upload MSI
shell: bash
run: hub release edit "${GITHUB_REF#refs/tags/}" -m "" --draft=false -a "${{ steps.buildmsi.outputs.msi }}"
run: |
tag_name="${GITHUB_REF#refs/tags/}"
hub release edit "$tag_name" -m "" -a "$MSI_FILE"
release_url="$(gh api repos/:owner/:repo/releases -q ".[]|select(.tag_name==\"${tag_name}\")|.url")"
publish_args=( -F draft=false )
if [[ $GITHUB_REF != *-* ]]; then
publish_args+=( -f discussion_category_name="$DISCUSSION_CATEGORY" )
fi
gh api -X PATCH "$release_url" "${publish_args[@]}"
env:
MSI_FILE: ${{ steps.buildmsi.outputs.msi }}
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v1

View file

@ -6,13 +6,14 @@ import (
)
func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(issue).Elem()
data := map[string]interface{}{}
for _, f := range fields {
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
}
@ -25,7 +26,6 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
case "projectCards":
data[f] = issue.ProjectCards.Nodes
default:
v := reflect.ValueOf(issue).Elem()
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
@ -35,6 +35,7 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
}
func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(pr).Elem()
data := map[string]interface{}{}
for _, f := range fields {
@ -43,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
}
@ -75,7 +76,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
}
data[f] = &requests
default:
v := reflect.ValueOf(pr).Elem()
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
@ -84,22 +84,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
return &data
}
func ExportIssues(issues []Issue, fields []string) *[]interface{} {
data := make([]interface{}, len(issues))
for i := range issues {
data[i] = issues[i].ExportData(fields)
}
return &data
}
func ExportPRs(prs []PullRequest, fields []string) *[]interface{} {
data := make([]interface{}, len(prs))
for i := range prs {
data[i] = prs[i].ExportData(fields)
}
return &data
}
func fieldByName(v reflect.Value, field string) reflect.Value {
return v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(field, s)

View file

@ -90,31 +90,6 @@ func TestIssue_ExportData(t *testing.T) {
}
}
func TestExportIssues(t *testing.T) {
issues := []Issue{
{Milestone: Milestone{Title: "hi"}},
{},
}
exported := ExportIssues(issues, []string{"milestone"})
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, heredoc.Doc(`
[
{
"milestone": {
"title": "hi"
}
},
{
"milestone": null
}
]
`), buf.String())
}
func TestPullRequest_ExportData(t *testing.T) {
tests := []struct {
name string

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 {
@ -241,7 +244,6 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
id
title
state
closed
body
author {
login

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,16 +439,26 @@ type repositoryV3 struct {
NodeID string
Name string
CreatedAt time.Time `json:"created_at"`
CloneURL string `json:"clone_url"`
Owner struct {
Login string
}
}
// ForkRepo forks the repository on GitHub and returns the new repository
func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, error) {
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
body := bytes.NewBufferString(`{}`)
params := map[string]interface{}{}
if org != "" {
params["organization"] = org
}
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 {
@ -324,7 +468,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 +850,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)

84
docs/project-layout.md Normal file
View file

@ -0,0 +1,84 @@
# GitHub CLI project layout
At a high level, these areas make up the `github.com/cli/cli` project:
- [`cmd/`](../cmd) - `main` packages for building binaries such as the `gh` executable
- [`pkg/`](../pkg) - most other packages, including the implementation for individual gh commands
- [`docs/`](../docs) - documentation for maintainers and contributors
- [`script/`](../script) - build and release scripts
- [`internal/`](../internal) - Go packages highly specific to our needs and thus internal
- [`go.mod`](../go.mod) - external Go dependencies for this project, automatically fetched by Go at build time
Some auxiliary Go packages are at the top level of the project for historical reasons:
- [`api/`](../api) - main utilities for making requests to the GitHub API
- [`context/`](../context) - DEPRECATED: use only for referencing git remotes
- [`git/`](../git) - utilities to gather information from a local git repository
- [`test/`](../test) - DEPRECATED: do not use
- [`utils/`](../utils) - DEPRECATED: use only for printing table output
## Command-line help text
Running `gh help issue list` displays help text for a topic. In this case, the topic is a specific command,
and help text for every command is embedded in that command's source code. The naming convention for gh
commands is:
```
pkg/cmd/<command>/<subcommand>/<subcommand>.go
```
Following the above example, the main implementation for the `gh issue list` command, including its help
text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go)
Other help topics not specific to any command, for example `gh help environment`, are found in
[pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go).
During our release process, these help topics are [automatically converted](../cmd/gen-docs/main.go) to
manual pages and published under https://cli.github.com/manual/.
## How GitHub CLI works
To illustrate how GitHub CLI works in its typical mode of operation, let's build the project, run a command,
and talk through which code gets run in order.
1. `go run script/build.go` - Makes sure all external Go depedencies are fetched, then compiles the
`cmd/gh/main.go` file into a `bin/gh` binary.
2. `bin/gh issue list --limit 5` - Runs the newly built `bin/gh` binary (note: on Windows you must use
backslashes like `bin\gh`) and passes the following arguments to the process: `["issue", "list", "--limit", "5"]`.
3. `func main()` inside `cmd/gh/main.go` is the first Go function that runs. The arguments passed to the
process are available through `os.Args`.
4. The `main` package initializes the "root" command with `root.NewCmdRoot()` and dispatches execution to it
with `rootCmd.ExecuteC()`.
5. The [root command](../pkg/cmd/root/root.go) represents the top-level `gh` command and knows how to
dispatch execution to any other gh command nested under it.
6. Based on `["issue", "list"]` arguments, the execution reaches the `RunE` block of the `cobra.Command`
within [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go).
7. The `--limit 5` flag originally passed as arguments be automatically parsed and its value stored as
`opts.LimitResults`.
8. `func listRun()` is called, which is responsible for implementing the logic of the `gh issue list` command.
9. The command collects information from sources like the GitHub API then writes the final output to
standard output and standard error [streams](../pkg/iostreams/iostreams.go) available at `opts.IO`.
10. The program execution is now back at `func main()` of `cmd/gh/main.go`. If there were any Go errors as a
result of processing the command, the function will abort the process with a non-zero exit status.
Otherwise, the process ends with status 0 indicating success.
## How to add a new command
0. First, check on our issue tracker to verify that our team had approved the plans for a new command.
1. Create a package for the new command, e.g. for a new command `gh boom` create the following directory
structure: `pkg/cmd/boom/`
2. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and
returns a `*cobra.Command`.
* Any logic specific to this command should be kept within the command's package and not added to any
"global" packages like `api` or `utils`.
3. Use the method from the previous step to generate the command and add it to the command tree, typically
somewhere in the `NewCmdRoot()` method.
## How to write tests
This task might be tricky. Typically, gh commands do things like look up information from the git repository
in the current directory, query the GitHub API, scan the user's `~/.ssh/config` file, clone or fetch git
repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless
you are sure that any filesystem operations are stricly scoped to a location made for and maintained by the
test itself. To avoid actually running things like making real API requests or shelling out to `git`
commands, we stub them. You should look at how that's done within some existing tests.
To make your code testable, write small, isolated pieces of functionality that are designed to be composed
together. Prefer table-driven tests for maintaining variations of different test inputs and expectations
when exercising a single piece of functionality.

2
go.mod
View file

@ -27,7 +27,7 @@ require (
github.com/rivo/uniseg v0.1.0
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/spf13/cobra v1.1.1
github.com/spf13/cobra v1.1.3
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897

6
go.sum
View file

@ -248,8 +248,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@ -419,7 +419,7 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -175,11 +175,10 @@ func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error {
}
func BenchmarkGenManToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
file, err := ioutil.TempFile(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()

View file

@ -83,11 +83,10 @@ func TestGenMdTree(t *testing.T) {
}
func BenchmarkGenMarkdownToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
file, err := ioutil.TempFile(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()

View file

@ -48,26 +48,26 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
GitHub CLI integrates with Actions to help you manage runs and workflows.
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
gh run download: Download artifacts generated by runs
To see more help, run 'gh help run <subcommand>'
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
gh workflow run: Trigger a workflow_dispatch run for a workflow file
To see more help, run 'gh help workflow <subcommand>'
For more in depth help including examples, see online documentation at:
https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli
<https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli>
`, header, runHeader, workflowHeader)
}

View file

@ -71,8 +71,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
get replaced with values from the repository of the current directory.
Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will
get replaced with values from the repository of the current directory. Note that in
some shells, for example PowerShell, you may need to enclose any value that contains
"{...}" in quotes to prevent the shell from applying special meaning to curly braces.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with %[1]s--method%[1]s.
@ -87,7 +89,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner", ":repo", and ":branch" get populated with values
- placeholder values "{owner}", "{repo}", and "{branch}" get populated with values
from the repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
@ -106,10 +108,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
`, "`"),
Example: heredoc.Doc(`
# list releases in the current repository
$ gh api repos/:owner/:repo/releases
$ gh api repos/{owner}/{repo}/releases
# post an issue comment
$ gh api repos/:owner/:repo/issues/123/comments -f body='Hi from CLI'
$ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'
# add parameters to a GET request
$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
@ -121,14 +123,14 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
$ gh api --preview baptiste,nebula ...
# print only specific fields from the response
$ gh api repos/:owner/:repo/issues --jq '.[].title'
$ gh api repos/{owner}/{repo}/issues --jq '.[].title'
# use a template for the output
$ gh api repos/:owner/:repo/issues --template \
$ gh api repos/{owner}/{repo}/issues --template \
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
# list releases with GraphQL
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
releases(last: 3) {
@ -397,41 +399,41 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
return
}
var placeholderRE = regexp.MustCompile(`\:(owner|repo|branch)\b`)
var placeholderRE = regexp.MustCompile(`(\:(owner|repo|branch)\b|\{[a-z]+\})`)
// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
// fillPlaceholders replaces placeholders with values from the current repository
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
if !placeholderRE.MatchString(value) {
return value, nil
}
var err error
return placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
var name string
if m[0] == ':' {
name = m[1:]
} else {
name = m[1 : len(m)-1]
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return value, err
}
filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
switch m {
case ":owner":
return baseRepo.RepoOwner()
case ":repo":
return baseRepo.RepoName()
case ":branch":
branch, e := opts.Branch()
if e != nil {
switch name {
case "owner":
if baseRepo, e := opts.BaseRepo(); e == nil {
return baseRepo.RepoOwner()
} else {
err = e
}
case "repo":
if baseRepo, e := opts.BaseRepo(); e == nil {
return baseRepo.RepoName()
} else {
err = e
}
case "branch":
if branch, e := opts.Branch(); e == nil {
return branch
} else {
err = e
}
return branch
default:
panic(fmt.Sprintf("invalid placeholder: %q", m))
}
})
if err != nil {
return value, err
}
return filled, nil
return m
}), err
}
func printHeaders(w io.Writer, headers http.Header, colorize bool) {

View file

@ -693,6 +693,9 @@ func Test_apiRun_inputFile(t *testing.T) {
contentLength: 10,
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
@ -702,13 +705,12 @@ func Test_apiRun_inputFile(t *testing.T) {
if tt.inputFile == "-" {
_, _ = stdin.Write(tt.inputContents)
} else {
f, err := ioutil.TempFile("", tt.inputFile)
f, err := ioutil.TempFile(tempDir, tt.inputFile)
if err != nil {
t.Fatal(err)
}
_, _ = f.Write(tt.inputContents)
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
defer f.Close()
inputFile = f.Name()
}
@ -825,13 +827,13 @@ func Test_parseFields(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()
@ -870,7 +872,7 @@ func Test_magicFieldValue(t *testing.T) {
wantErr: false,
},
{
name: "placeholder",
name: "placeholder colon",
args: args{
v: ":owner",
opts: &ApiOptions{
@ -883,6 +885,20 @@ func Test_magicFieldValue(t *testing.T) {
want: "hubot",
wantErr: false,
},
{
name: "placeholder braces",
args: args{
v: "{owner}",
opts: &ApiOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "hubot",
wantErr: false,
},
{
name: "file",
args: args{
@ -918,13 +934,13 @@ func Test_magicFieldValue(t *testing.T) {
}
func Test_openUserFile(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
file, length, err := openUserFile(f.Name(), nil)
if err != nil {
@ -964,7 +980,7 @@ func Test_fillPlaceholders(t *testing.T) {
wantErr: false,
},
{
name: "has substitutes",
name: "has substitutes (colon)",
args: args{
value: "repos/:owner/:repo/releases",
opts: &ApiOptions{
@ -977,39 +993,96 @@ func Test_fillPlaceholders(t *testing.T) {
wantErr: false,
},
{
name: "has branch placeholder",
name: "has branch placeholder (colon)",
args: args{
value: "repos/cli/cli/branches/:branch/protection/required_status_checks",
value: "repos/owner/repo/branches/:branch/protection/required_status_checks",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("cli", "cli"), nil
},
BaseRepo: nil,
Branch: func() (string, error) {
return "trunk", nil
},
},
},
want: "repos/cli/cli/branches/trunk/protection/required_status_checks",
want: "repos/owner/repo/branches/trunk/protection/required_status_checks",
wantErr: false,
},
{
name: "has branch placeholder and git is in detached head",
name: "has branch placeholder and git is in detached head (colon)",
args: args{
value: "repos/:owner/:repo/branches/:branch",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("cli", "cli"), nil
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "repos/:owner/:repo/branches/:branch",
want: "repos/hubot/robot-uprising/branches/:branch",
wantErr: true,
},
{
name: "no greedy substitutes",
name: "has substitutes",
args: args{
value: "repos/{owner}/{repo}/releases",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "repos/hubot/robot-uprising/releases",
wantErr: false,
},
{
name: "has branch placeholder",
args: args{
value: "repos/owner/repo/branches/{branch}/protection/required_status_checks",
opts: &ApiOptions{
BaseRepo: nil,
Branch: func() (string, error) {
return "trunk", nil
},
},
},
want: "repos/owner/repo/branches/trunk/protection/required_status_checks",
wantErr: false,
},
{
name: "has branch placeholder and git is in detached head",
args: args{
value: "repos/{owner}/{repo}/branches/{branch}",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "repos/hubot/robot-uprising/branches/{branch}",
wantErr: true,
},
{
name: "surfaces errors in earlier placeholders",
args: args{
value: "{branch}-{owner}",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "{branch}-hubot",
wantErr: true,
},
{
name: "no greedy substitutes (colon)",
args: args{
value: ":ownership/:repository",
opts: &ApiOptions{
@ -1019,6 +1092,17 @@ func Test_fillPlaceholders(t *testing.T) {
want: ":ownership/:repository",
wantErr: false,
},
{
name: "non-placeholders are left intact",
args: args{
value: "{}{ownership}/{repository}",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: "{}{ownership}/{repository}",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -21,7 +21,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
When installing GitHub CLI through a package manager, it's possible that
no additional shell configuration is necessary to gain completion support. For
Homebrew, see https://docs.brew.sh/Shell-Completion
Homebrew, see <https://docs.brew.sh/Shell-Completion>
If you need to set up completions manually, follow the instructions below. The exact
config file locations might vary based on your system. Make sure to restart your

View file

@ -151,7 +151,13 @@ func createRun(opts *CreateOptions) error {
var httpError api.HTTPError
if errors.As(err, &httpError) {
if httpError.OAuthScopes != "" && !strings.Contains(httpError.OAuthScopes, "gist") {
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate by doing `gh config set -h github.com oauth_token ''` and running the command again.")
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h %s -s gist", host)
}
if httpError.StatusCode == http.StatusUnprocessableEntity {
if detectEmptyFiles(files) {
fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank")
return cmdutil.SilentError
}
}
}
return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err)
@ -266,3 +272,12 @@ func createGist(client *http.Client, hostname, description string, public bool,
return &result, nil
}
func detectEmptyFiles(files map[string]*shared.GistFile) bool {
for _, file := range files {
if strings.TrimSpace(file.Content) == "" {
return true
}
}
return false
}

View file

@ -5,9 +5,11 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"path"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/gist/shared"
@ -18,10 +20,6 @@ import (
"github.com/stretchr/testify/assert"
)
const (
fixtureFile = "../fixture.txt"
)
func Test_processFiles(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"})
@ -164,15 +162,22 @@ func TestNewCmdCreate(t *testing.T) {
}
func Test_createRun(t *testing.T) {
tempDir := t.TempDir()
fixtureFile := path.Join(tempDir, "fixture.txt")
assert.NoError(t, ioutil.WriteFile(fixtureFile, []byte("{}"), 0644))
emptyFile := path.Join(tempDir, "empty.txt")
assert.NoError(t, ioutil.WriteFile(emptyFile, []byte(" \t\n"), 0644))
tests := []struct {
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
responseStatus int
}{
{
name: "public",
@ -193,6 +198,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "with description",
@ -213,6 +219,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "multiple files",
@ -236,6 +243,28 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "file with empty content",
opts: &CreateOptions{
Filenames: []string{emptyFile},
},
wantOut: "",
wantStderr: heredoc.Doc(`
- Creating gist empty.txt
X Failed to create gist: a gist file cannot be blank
`),
wantErr: true,
wantParams: map[string]interface{}{
"description": "",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"empty.txt": map[string]interface{}{"content": " \t\n"},
},
},
responseStatus: http.StatusUnprocessableEntity,
},
{
name: "stdin arg",
@ -256,6 +285,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "web arg",
@ -277,14 +307,22 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"),
httpmock.JSONResponse(struct {
Html_url string
}{"https://gist.github.com/aa5a315d61ae9438b18d"}))
if tt.responseStatus == http.StatusOK {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StringResponse(`{
"html_url": "https://gist.github.com/aa5a315d61ae9438b18d"
}`))
} else {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StatusStringResponse(tt.responseStatus, "{}"))
}
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
@ -325,6 +363,32 @@ func Test_createRun(t *testing.T) {
}
}
func Test_detectEmptyFiles(t *testing.T) {
tests := []struct {
content string
isEmptyFile bool
}{
{
content: "{}",
isEmptyFile: false,
},
{
content: "\n\t",
isEmptyFile: true,
},
}
for _, tt := range tests {
files := map[string]*shared.GistFile{}
files["file"] = &shared.GistFile{
Content: tt.content,
}
isEmptyFile := detectEmptyFiles(files)
assert.Equal(t, tt.isEmptyFile, isEmptyFile)
}
}
func Test_CreateRun_reauth(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"), func(req *http.Request) (*http.Response, error) {
@ -332,33 +396,24 @@ func Test_CreateRun_reauth(t *testing.T) {
StatusCode: 404,
Request: req,
Header: map[string][]string{
"X-Oauth-Scopes": {"coolScope"},
"X-Oauth-Scopes": {"repo, read:org"},
},
Body: ioutil.NopCloser(bytes.NewBufferString("oh no")),
}, nil
})
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, _, _ := iostreams.Test()
opts := &CreateOptions{
IO: io,
HttpClient: mockClient,
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
Filenames: []string{fixtureFile},
}
err := createRun(opts)
if err == nil {
t.Fatalf("expected oauth error")
}
if !strings.Contains(err.Error(), "Please re-authenticate") {
t.Errorf("got unexpected error: %s", err)
}
assert.EqualError(t, err, "This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h github.com -s gist")
}

View file

@ -1 +0,0 @@
{}

View file

@ -20,7 +20,7 @@ type GistFile struct {
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Language string `json:"language,omitempty"`
Content string `json:"content,omitempty"`
Content string `json:"content"`
}
type GistOwner struct {

View file

@ -65,7 +65,7 @@ func closeRun(opts *CloseOptions) error {
return err
}
if issue.Closed {
if issue.State == "CLOSED" {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", cs.Yellow("!"), issue.Number, issue.Title)
return nil
}

View file

@ -96,7 +96,7 @@ func TestIssueClose_alreadyClosed(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
"issue": { "number": 13, "title": "The title of the issue", "state": "CLOSED"}
} } }`),
)

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@ -422,8 +421,9 @@ func TestIssueCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportIssues(listResult.Issues, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
}
if isTerminal {

View file

@ -65,7 +65,7 @@ func reopenRun(opts *ReopenOptions) error {
return err
}
if !issue.Closed {
if issue.State == "OPEN" {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", cs.Yellow("!"), issue.Number, issue.Title)
return nil
}

View file

@ -64,7 +64,7 @@ func TestIssueReopen(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 2, "closed": true, "title": "The title of the issue"}
"issue": { "id": "THE-ID", "number": 2, "state": "CLOSED", "title": "The title of the issue"}
} } }`),
)
http.Register(
@ -96,7 +96,7 @@ func TestIssueReopen_alreadyOpen(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": false, "title": "The title of the issue"}
"issue": { "number": 2, "state": "OPEN", "title": "The title of the issue"}
} } }`),
)

View file

@ -96,11 +96,11 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Exporter.Fields()),
"assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Exporter.Fields()),
"mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Exporter.Fields()),
"createdBy": issuePayload.Authored.Issues,
"assigned": issuePayload.Assigned.Issues,
"mentioned": issuePayload.Mentioned.Issues,
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -118,7 +118,7 @@ func Test_transferRunSuccessfulIssueTransfer(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 1234, "closed": true, "title": "The title of the issue"}
"issue": { "id": "THE-ID", "number": 1234, "title": "The title of the issue"}
} } }`))
http.Register(

View file

@ -116,8 +116,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportIssue := issue.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {

View file

@ -681,7 +681,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
opts.IO.StartProgressIndicator()
headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "")
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
@ -305,8 +304,9 @@ func TestPRCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportPRs(listResult.PullRequests, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"io/ioutil"
"os"
"testing"
"github.com/cli/cli/pkg/iostreams"
@ -70,6 +69,8 @@ func Test_PreserveInput(t *testing.T) {
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.state == nil {
@ -78,9 +79,9 @@ func Test_PreserveInput(t *testing.T) {
io, _, _, errOut := iostreams.Test()
tf, tferr := tmpfile()
tf, tferr := ioutil.TempFile(tempDir, "testfile*")
assert.NoError(t, tferr)
defer os.Remove(tf.Name())
defer tf.Close()
io.TempFileOverride = tf
@ -111,13 +112,3 @@ func Test_PreserveInput(t *testing.T) {
})
}
}
func tmpfile() (*os.File, error) {
dir := os.TempDir()
tmpfile, err := ioutil.TempFile(dir, "testfile*")
if err != nil {
return nil, err
}
return tmpfile, nil
}

View file

@ -113,13 +113,13 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"currentBranch": nil,
"createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Exporter.Fields()),
"needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Exporter.Fields()),
"createdBy": prPayload.ViewerCreated.PullRequests,
"needsReview": prPayload.ReviewRequested.PullRequests,
}
if prPayload.CurrentPR != nil {
data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Exporter.Fields())
data["currentBranch"] = prPayload.CurrentPR
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -119,8 +119,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportPR := pr.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
}
if connectedToTerminal {

View file

@ -36,6 +36,7 @@ type ForkOptions struct {
PromptClone bool
PromptRemote bool
RemoteName string
Organization string
Rename bool
}
@ -110,6 +111,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
return cmd
}
@ -169,7 +171,7 @@ func forkRun(opts *ForkOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to fork: %w", err)

View file

@ -2,12 +2,14 @@ package fork
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -72,8 +74,9 @@ func TestNewCmdFork(t *testing.T) {
name: "blank nontty",
cli: "",
wants: ForkOptions{
RemoteName: "origin",
Rename: true,
RemoteName: "origin",
Rename: true,
Organization: "",
},
},
{
@ -85,6 +88,7 @@ func TestNewCmdFork(t *testing.T) {
PromptClone: true,
PromptRemote: true,
Rename: true,
Organization: "",
},
},
{
@ -104,6 +108,16 @@ func TestNewCmdFork(t *testing.T) {
Rename: true,
},
},
{
name: "to org",
cli: "--org batmanshome",
wants: ForkOptions{
RemoteName: "origin",
Remote: false,
Rename: false,
Organization: "batmanshome",
},
},
}
for _, tt := range tests {
@ -141,6 +155,7 @@ func TestNewCmdFork(t *testing.T) {
assert.Equal(t, tt.wants.Remote, gotOpts.Remote)
assert.Equal(t, tt.wants.PromptRemote, gotOpts.PromptRemote)
assert.Equal(t, tt.wants.PromptClone, gotOpts.PromptClone)
assert.Equal(t, tt.wants.Organization, gotOpts.Organization)
})
}
}
@ -289,6 +304,7 @@ func TestRepoFork_in_parent_tty(t *testing.T) {
assert.Equal(t, "✓ Created fork someone/REPO\n✓ Added remote origin\n", output.Stderr())
reg.Verify(t)
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
@ -409,37 +425,65 @@ func TestRepoFork_in_parent(t *testing.T) {
func TestRepoFork_outside(t *testing.T) {
tests := []struct {
name string
args string
name string
args string
postBody string
responseBody string
wantStderr string
}{
{
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "full name arg",
args: "--clone=false OWNER/REPO",
name: "full name arg",
args: "--clone=false OWNER/REPO",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "fork to org without clone",
args: "--clone=false OWNER/REPO --org batmanshome",
postBody: "{\"organization\":\"batmanshome\"}\n",
responseBody: `{"name":"REPO", "owner":{"login":"BatmansHome"}}`,
wantStderr: heredoc.Doc(`
Created fork BatmansHome/REPO
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/forks"),
func(req *http.Request) (*http.Response, error) {
bb, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
assert.Equal(t, tt.postBody, string(bb))
return &http.Response{
Request: req,
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(tt.responseBody)),
}, nil
})
httpClient := &http.Client{Transport: reg}
output, err := runCommand(httpClient, nil, true, tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.NoError(t, err)
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
assert.Equal(t, tt.wantStderr, output.Stderr())
reg.Verify(t)
})
}

View file

@ -1,48 +1,18 @@
package list
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/githubsearch"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Repository struct {
NameWithOwner string
Description string
IsFork bool
IsPrivate bool
IsArchived bool
PushedAt time.Time
}
func (r Repository) Info() string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}
type RepositoryList struct {
Owner string
Repositories []Repository
Repositories []api.Repository
TotalCount int
FromSearch bool
}
@ -54,6 +24,7 @@ type FilterOptions struct {
Language string
Archived bool
NonArchived bool
Fields []string
}
func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
@ -67,62 +38,65 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi
}
variables := map[string]interface{}{
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"perPage": githubv4.Int(perPage),
}
if filter.Visibility != "" {
variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility))
} else {
variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil)
}
if filter.Fork {
variables["fork"] = githubv4.Boolean(true)
} else if filter.Source {
variables["fork"] = githubv4.Boolean(false)
} else {
variables["fork"] = (*githubv4.Boolean)(nil)
}
inputs := []string{"$perPage:Int!", "$endCursor:String", "$privacy:RepositoryPrivacy", "$fork:Boolean"}
var ownerConnection string
if owner == "" {
ownerConnection = `graphql:"repositoryOwner: viewer"`
ownerConnection = "repositoryOwner: viewer"
} else {
ownerConnection = `graphql:"repositoryOwner(login: $owner)"`
ownerConnection = "repositoryOwner(login: $owner)"
variables["owner"] = githubv4.String(owner)
inputs = append(inputs, "$owner:String!")
}
type repositoryOwner struct {
Login string
Repositories struct {
Nodes []Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
type result struct {
RepositoryOwner struct {
Login string
Repositories struct {
Nodes []api.Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
} `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"`
}
}
query := reflect.StructOf([]reflect.StructField{
{
Name: "RepositoryOwner",
Type: reflect.TypeOf(repositoryOwner{}),
Tag: reflect.StructTag(ownerConnection),
},
})
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
query := fmt.Sprintf(`query RepositoryList(%s) {
%s {
login
repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC }) {
nodes{%s}
totalCount
pageInfo{hasNextPage,endCursor}
}
}
}`, strings.Join(inputs, ","), ownerConnection, api.RepositoryGraphQL(filter.Fields))
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{}
pagination:
for {
result := reflect.New(query)
err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables)
var res result
err := apiClient.GraphQL(hostname, query, variables, &res)
if err != nil {
return nil, err
}
owner := result.Elem().FieldByName("RepositoryOwner").Interface().(repositoryOwner)
owner := res.RepositoryOwner
listResult.TotalCount = owner.Repositories.TotalCount
listResult.Owner = owner.Login
@ -143,47 +117,52 @@ pagination:
}
func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
type query struct {
type result struct {
Search struct {
RepositoryCount int
Nodes []struct {
Repository Repository `graphql:"...on Repository"`
}
PageInfo struct {
Nodes []api.Repository
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"`
}
}
query := fmt.Sprintf(`query RepositoryListSearch($query:String!,$perPage:Int!,$endCursor:String) {
search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor) {
repositoryCount
nodes{...on Repository{%s}}
pageInfo{hasNextPage,endCursor}
}
}`, api.RepositoryGraphQL(filter.Fields))
perPage := limit
if perPage > 100 {
perPage = 100
}
variables := map[string]interface{}{
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{FromSearch: true}
pagination:
for {
var result query
err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables)
var result result
err := apiClient.GraphQL(hostname, query, variables, &result)
if err != nil {
return nil, err
}
listResult.TotalCount = result.Search.RepositoryCount
for _, node := range result.Search.Nodes {
if listResult.Owner == "" {
idx := strings.IndexRune(node.Repository.NameWithOwner, '/')
listResult.Owner = node.Repository.NameWithOwner[:idx]
for _, repo := range result.Search.Nodes {
if listResult.Owner == "" && repo.NameWithOwner != "" {
idx := strings.IndexRune(repo.NameWithOwner, '/')
listResult.Owner = repo.NameWithOwner[:idx]
}
listResult.Repositories = append(listResult.Repositories, node.Repository)
listResult.Repositories = append(listResult.Repositories, repo)
if len(listResult.Repositories) >= limit {
break pagination
}

View file

@ -3,8 +3,10 @@ package list
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -17,6 +19,7 @@ type ListOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Exporter cmdutil.Exporter
Limit int
Owner string
@ -88,10 +91,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language")
cmd.Flags().BoolVar(&opts.Archived, "archived", false, "Show only archived repositories")
cmd.Flags().BoolVar(&opts.NonArchived, "no-archived", false, "Omit archived repositories")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"nameWithOwner", "description", "isPrivate", "isFork", "isArchived", "createdAt", "pushedAt"}
func listRun(opts *ListOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -105,6 +111,10 @@ func listRun(opts *ListOptions) error {
Language: opts.Language,
Archived: opts.Archived,
NonArchived: opts.NonArchived,
Fields: defaultFields,
}
if opts.Exporter != nil {
filter.Fields = opts.Exporter.Fields()
}
cfg, err := opts.Config()
@ -127,27 +137,31 @@ func listRun(opts *ListOptions) error {
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
}
cs := opts.IO.ColorScheme()
tp := utils.NewTablePrinter(opts.IO)
now := opts.Now()
for _, repo := range listResult.Repositories {
info := repo.Info()
info := repoInfo(repo)
infoColor := cs.Gray
if repo.IsPrivate {
infoColor = cs.Yellow
}
t := repo.PushedAt
// if listResult.FromSearch {
// t = repo.UpdatedAt
// }
if repo.PushedAt == nil {
t = &repo.CreatedAt
}
tp.AddField(repo.NameWithOwner, nil, cs.Bold)
tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil)
tp.AddField(info, nil, infoColor)
if tp.IsTTY() {
tp.AddField(utils.FuzzyAgoAbbr(now, t), nil, cs.Gray)
tp.AddField(utils.FuzzyAgoAbbr(now, *t), nil, cs.Gray)
} else {
tp.AddField(t.Format(time.RFC3339), nil, nil)
}
@ -179,3 +193,21 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool)
}
return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr)
}
func repoInfo(r api.Repository) string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}

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
}

View file

@ -7,7 +7,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"github.com/cli/cli/api"
@ -149,13 +148,13 @@ func TestNewCmdRun(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"reflect"
"sort"
"strings"
@ -26,6 +27,21 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`")
f.StringP("template", "t", "", "Format JSON output using a Go template")
_ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
if idx := strings.IndexRune(toComplete, ','); idx >= 0 {
toComplete = toComplete[idx+1:]
}
toComplete = strings.ToLower(toComplete)
for _, f := range fields {
if strings.HasPrefix(strings.ToLower(f), toComplete) {
results = append(results, f)
}
}
sort.Strings(results)
return results, cobra.ShellCompDirectiveNoSpace
})
oldPreRun := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if oldPreRun != nil {
@ -102,11 +118,14 @@ func (e *exportFormat) Fields() []string {
return e.fields
}
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
// raw data for serialization.
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
buf := bytes.Buffer{}
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
if err := encoder.Encode(e.exportData(reflect.ValueOf(data))); err != nil {
return err
}
@ -121,3 +140,44 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e
_, err := io.Copy(w, &buf)
return err
}
func (e *exportFormat) exportData(v reflect.Value) interface{} {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
if !v.IsNil() {
return e.exportData(v.Elem())
}
case reflect.Slice:
a := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
a[i] = e.exportData(v.Index(i))
}
return a
case reflect.Map:
t := reflect.MapOf(v.Type().Key(), emptyInterfaceType)
m := reflect.MakeMapWithSize(t, v.Len())
iter := v.MapRange()
for iter.Next() {
ve := reflect.ValueOf(e.exportData(iter.Value()))
m.SetMapIndex(iter.Key(), ve)
}
return m.Interface()
case reflect.Struct:
if v.CanAddr() && reflect.PtrTo(v.Type()).Implements(exportableType) {
ve := v.Addr().Interface().(exportable)
return ve.ExportData(e.fields)
} else if v.Type().Implements(exportableType) {
ve := v.Interface().(exportable)
return ve.ExportData(e.fields)
}
}
return v.Interface()
}
type exportable interface {
ExportData([]string) *map[string]interface{}
}
var exportableType = reflect.TypeOf((*exportable)(nil)).Elem()
var sliceOfEmptyInterface []interface{}
var emptyInterfaceType = reflect.TypeOf(sliceOfEmptyInterface).Elem()

View file

@ -2,6 +2,7 @@ package cmdutil
import (
"bytes"
"fmt"
"io/ioutil"
"testing"
@ -137,6 +138,29 @@ func Test_exportFormat_Write(t *testing.T) {
wantW: "{\"name\":\"hubot\"}\n",
wantErr: false,
},
{
name: "call ExportData",
exporter: exportFormat{fields: []string{"field1", "field2"}},
args: args{
data: &exportableItem{"item1"},
colorEnabled: false,
},
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
wantErr: false,
},
{
name: "recursively call ExportData",
exporter: exportFormat{fields: []string{"f1", "f2"}},
args: args{
data: map[string]interface{}{
"s1": []exportableItem{{"i1"}, {"i2"}},
"s2": []exportableItem{{"i3"}},
},
colorEnabled: false,
},
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
wantErr: false,
},
{
name: "with jq filter",
exporter: exportFormat{filter: ".name"},
@ -166,8 +190,20 @@ func Test_exportFormat_Write(t *testing.T) {
return
}
if gotW := w.String(); gotW != tt.wantW {
t.Errorf("exportFormat.Write() = %v, want %v", gotW, tt.wantW)
t.Errorf("exportFormat.Write() = %q, want %q", gotW, tt.wantW)
}
})
}
}
type exportableItem struct {
Name string
}
func (e *exportableItem) ExportData(fields []string) *map[string]interface{} {
m := map[string]interface{}{}
for _, f := range fields {
m[f] = fmt.Sprintf("%s:%s", e.Name, f)
}
return &m
}

View file

@ -261,12 +261,11 @@ func TestFindLegacy(t *testing.T) {
}
func TestExtractName(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string
@ -322,12 +321,11 @@ about: This is how you report bugs
}
func TestExtractContents(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string

View file

@ -10,7 +10,7 @@ import (
const (
colorDelim = "1;38" // bright white
colorKey = "1;34" // bright blue
colorNull = "1;30" // gray
colorNull = "36" // cyan
colorString = "32" // green
colorBool = "33" // yellow
)