Merge branch 'trunk' into readme-updates

This commit is contained in:
Amanda Pinsker 2020-09-14 15:06:03 -04:00 committed by GitHub
commit f43a65a69f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 4827 additions and 374 deletions

View file

@ -78,7 +78,7 @@ jobs:
popd
- name: Run reprepro
env:
RELEASES: "focal stable"
RELEASES: "focal stable bionic trusty precise xenial"
run: |
mkdir -p upload
for release in $RELEASES; do

View file

@ -22,8 +22,8 @@ endif
GO_LDFLAGS := -X github.com/cli/cli/command.Version=$(GH_VERSION) $(GO_LDFLAGS)
GO_LDFLAGS := -X github.com/cli/cli/command.BuildDate=$(BUILD_DATE) $(GO_LDFLAGS)
ifdef GH_OAUTH_CLIENT_SECRET
GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS)
GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS)
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS)
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS)
endif
bin/gh: $(BUILD_FILES)

View file

@ -14,6 +14,7 @@ GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterpr
Read the [official docs][] for usage and more information.
## We want your feedback
We'd love to hear your feedback about `gh`. If you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][].

View file

@ -45,7 +45,9 @@ func NewClientFromHTTP(httpClient *http.Client) *Client {
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
req.Header.Add(name, value)
if req.Header.Get(name) == "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
@ -55,11 +57,16 @@ func AddHeader(name, value string) ClientOption {
func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if req.Header.Get(name) != "" {
return tr.RoundTrip(req)
}
value, err := getValue(req)
if err != nil {
return nil, err
}
req.Header.Add(name, value)
if value != "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
@ -68,14 +75,15 @@ func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) Cl
// VerboseLog enables request/response logging within a RoundTripper
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
logger := &httpretty.Logger{
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
MaxResponseBody: 10000,
}
logger.SetOutput(out)
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
@ -190,7 +198,9 @@ type HTTPError struct {
}
func (err HTTPError) Error() string {
if err.Message != "" {
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
} else if err.Message != "" {
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
}
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
@ -222,7 +232,7 @@ func (c Client) HasMinimumScopes(hostname string) error {
}()
if res.StatusCode != 200 {
return handleHTTPError(res)
return HandleHTTPError(res)
}
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
@ -298,7 +308,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return handleHTTPError(resp)
return HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
@ -322,7 +332,7 @@ func handleResponse(resp *http.Response, data interface{}) error {
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return handleHTTPError(resp)
return HandleHTTPError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
@ -342,13 +352,18 @@ func handleResponse(resp *http.Response, data interface{}) error {
return nil
}
func handleHTTPError(resp *http.Response) error {
func HandleHTTPError(resp *http.Response) error {
httpError := HTTPError{
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
OAuthScopes: resp.Header.Get("X-Oauth-Scopes"),
}
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
httpError.Message = resp.Status
return httpError
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
httpError.Message = err.Error()
@ -357,14 +372,57 @@ func handleHTTPError(resp *http.Response) error {
var parsedBody struct {
Message string `json:"message"`
Errors []json.RawMessage
}
if err := json.Unmarshal(body, &parsedBody); err == nil {
httpError.Message = parsedBody.Message
if err := json.Unmarshal(body, &parsedBody); err != nil {
return httpError
}
type errorObject struct {
Message string
Resource string
Field string
Code string
}
messages := []string{parsedBody.Message}
for _, raw := range parsedBody.Errors {
switch raw[0] {
case '"':
var errString string
_ = json.Unmarshal(raw, &errString)
messages = append(messages, errString)
case '{':
var errInfo errorObject
_ = json.Unmarshal(raw, &errInfo)
msg := errInfo.Message
if errInfo.Code != "custom" {
msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code))
}
if msg != "" {
messages = append(messages, msg)
}
}
}
httpError.Message = strings.Join(messages, "\n")
return httpError
}
func errorCodeToMessage(code string) string {
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors
switch code {
case "missing", "missing_field":
return "is missing"
case "invalid", "unprocessable":
return "is invalid"
case "already_exists":
return "already exists"
default:
return code
}
}
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
func inspectableMIMEType(t string) bool {

View file

@ -76,10 +76,19 @@ func TestRESTGetDelete(t *testing.T) {
}
func TestRESTError(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))
http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`))
fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 422,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)),
Header: map[string][]string{
"Content-Type": {"application/json; charset=utf-8"},
},
}, nil
})
var httpErr HTTPError
err := client.REST("github.com", "DELETE", "repos/branch", nil, nil)

View file

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
@ -72,12 +73,19 @@ type PullRequest struct {
TotalCount int
Nodes []struct {
Commit struct {
Oid string
StatusCheckRollup struct {
Contexts struct {
Nodes []struct {
State string
Status string
Conclusion string
Name string
Context string
State string
Status string
Conclusion string
StartedAt time.Time
CompletedAt time.Time
DetailsURL string
TargetURL string
}
}
}
@ -227,7 +235,7 @@ func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.Rea
if resp.StatusCode == 404 {
return nil, &NotFoundError{errors.New("pull request not found")}
} else if resp.StatusCode != 200 {
return nil, handleHTTPError(resp)
return nil, HandleHTTPError(resp)
}
return resp.Body, nil
@ -275,8 +283,8 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
state
}
...on CheckRun {
status
conclusion
status
}
}
}
@ -418,8 +426,32 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
author {
login
}
commits {
commits(last: 1) {
totalCount
nodes {
commit {
oid
statusCheckRollup {
contexts(last: 100) {
nodes {
...on StatusContext {
context
state
targetUrl
}
...on CheckRun {
name
status
conclusion
startedAt
completedAt
detailsUrl
}
}
}
}
}
}
}
baseRefName
headRefName
@ -524,8 +556,32 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
author {
login
}
commits {
commits(last: 1) {
totalCount
nodes {
commit {
oid
statusCheckRollup {
contexts(last: 100) {
nodes {
...on StatusContext {
context
state
targetUrl
}
...on CheckRun {
name
status
conclusion
startedAt
completedAt
detailsUrl
}
}
}
}
}
}
}
url
baseRefName
@ -981,11 +1037,18 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m
} `graphql:"mergePullRequest(input: $input)"`
}
input := githubv4.MergePullRequestInput{
PullRequestID: pr.ID,
MergeMethod: &mergeMethod,
}
if m == PullRequestMergeMethodSquash {
commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number))
input.CommitHeadline = &commitHeadline
}
variables := map[string]interface{}{
"input": githubv4.MergePullRequestInput{
PullRequestID: pr.ID,
MergeMethod: &mergeMethod,
},
"input": input,
}
gql := graphQLClient(client.http, repo.RepoHost())

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
@ -127,6 +128,19 @@ func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) {
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 {

View file

@ -45,6 +45,18 @@ func main() {
stderr := cmdFactory.IOStreams.ErrOut
rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate)
cfg, err := cmdFactory.Config()
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
os.Exit(2)
}
prompt, _ := cfg.Get("", "prompt")
if prompt == config.PromptsDisabled {
cmdFactory.IOStreams.SetNeverPrompt(true)
}
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
@ -55,12 +67,6 @@ func main() {
originalArgs := expandedArgs
isShell := false
cfg, err := cmdFactory.Config()
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
os.Exit(2)
}
expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil)
if err != nil {
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
@ -94,14 +100,8 @@ func main() {
authCheckEnabled := os.Getenv("GITHUB_TOKEN") == "" &&
os.Getenv("GITHUB_ENTERPRISE_TOKEN") == "" &&
cmdutil.IsAuthCheckEnabled(cmd)
cmd != nil && cmdutil.IsAuthCheckEnabled(cmd)
if authCheckEnabled {
cfg, err := cmdFactory.Config()
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
os.Exit(2)
}
if !cmdutil.CheckAuth(cfg) {
fmt.Fprintln(stderr, utils.Bold("Welcome to GitHub CLI!"))
fmt.Fprintln(stderr)

View file

@ -2,7 +2,7 @@
Packages downloaded from https://cli.github.com or from https://github.com/cli/cli/releases
are considered official binaries. We focus on a couple of popular Linux distros and
the following CPU architectures: `386`, `amd64`, `arm64`.
the following CPU architectures: `i386`, `amd64`, `arm64`.
Other sources for installation are community-maintained and thus might lag behind
our release schedule.
@ -43,8 +43,20 @@ sudo dnf install gh
### openSUSE/SUSE Linux (zypper)
It's possible that https://cli.github.com/packages/rpm/gh-cli.repo will work with zypper, but
this hasn't been tested.
Install:
```bash
sudo zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo
sudo zypper ref
sudo zypper install gh
```
Upgrade:
```bash
sudo zypper ref
sudo zypper update gh
```
## Manual installation

24
go.mod
View file

@ -3,28 +3,30 @@ module github.com/cli/cli
go 1.13
require (
github.com/AlecAivazis/survey/v2 v2.0.7
github.com/AlecAivazis/survey/v2 v2.1.1
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.11.1
github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684
github.com/google/go-cmp v0.4.1
github.com/google/go-cmp v0.5.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-version v1.2.0
github.com/henvic/httpretty v0.0.5
github.com/hashicorp/go-version v1.2.1
github.com/henvic/httpretty v0.0.6
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.6
github.com/mattn/go-colorable v0.1.7
github.com/mattn/go-isatty v0.0.12
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/mattn/go-runewidth v0.0.9
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/mitchellh/go-homedir v1.1.0
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4
github.com/rivo/uniseg v0.1.0
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/spf13/cobra v1.0.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.5.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/text v0.3.3
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
golang.org/x/text v0.3.3 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
)
replace github.com/shurcooL/graphql => github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e

37
go.sum
View file

@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc=
github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA=
github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI=
github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@ -65,8 +65,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
@ -76,11 +76,11 @@ github.com/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/henvic/httpretty v0.0.5 h1:XmOHN7HHt+ZhlLNzsMC54yncJDybipkP5NHOGVBOr1s=
github.com/henvic/httpretty v0.0.5/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
@ -107,6 +107,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@ -117,6 +119,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@ -147,13 +151,15 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4 h1:cjmR6xY0f89IwBYMSwUrkFs4/1+KKw30Df3SqT7nZ6Q=
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 h1:CA6Mjshr+g5YHENwllpQNR0UaYO7VGKo6TzJLM64WJQ=
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@ -176,8 +182,8 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@ -194,6 +200,8 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkpre
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -253,6 +261,7 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0=
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -1,4 +1,4 @@
package config
package authflow
import (
"bufio"
@ -10,6 +10,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/auth"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/browser"
"github.com/cli/cli/utils"
"github.com/mattn/go-colorable"
@ -22,14 +23,9 @@ var (
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
)
// IsGitHubApp reports whether an OAuth app is "GitHub CLI" or "GitHub CLI (dev)"
func IsGitHubApp(id string) bool {
// this intentionally doesn't use `oauthClientID` because that is a variable
// that can potentially be changed at build time via GH_OAUTH_CLIENT_ID
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
}
func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) {
func AuthFlowWithConfig(cfg config.Config, hostname, notice string, additionalScopes []string) (string, error) {
// TODO this probably shouldn't live in this package. It should probably be in a new package that
// depends on both iostreams and config.
stderr := colorable.NewColorableStderr()
token, userLogin, err := authFlow(hostname, stderr, notice, additionalScopes)

View file

@ -1,4 +1,4 @@
package config
package authflow
const oauthSuccessPage = `
<!doctype html>

View file

@ -110,7 +110,7 @@ github.com:
_, err := ParseConfig("config.yml")
assert.Nil(t, err)
expectedMain := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
expectedMain := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
expectedHosts := `github.com:
user: keiyuri
oauth_token: "123456"

View file

@ -10,7 +10,11 @@ import (
"gopkg.in/yaml.v3"
)
const defaultGitProtocol = "https"
const (
defaultGitProtocol = "https"
PromptsDisabled = "disabled"
PromptsEnabled = "enabled"
)
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
@ -167,6 +171,15 @@ func NewBlankRoot() *yaml.Node {
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled",
Kind: yaml.ScalarNode,
Value: "prompt",
},
{
Kind: yaml.ScalarNode,
Value: PromptsEnabled,
},
{
HeadComment: "Aliases allow you to create nicknames for gh commands",
Kind: yaml.ScalarNode,
@ -476,6 +489,8 @@ func defaultFor(key string) string {
switch key {
case "git_protocol":
return defaultGitProtocol
case "prompt":
return PromptsEnabled
default:
return ""
}

View file

@ -19,7 +19,7 @@ func Test_fileConfig_Set(t *testing.T) {
assert.NoError(t, c.Set("github.com", "user", "hubot"))
assert.NoError(t, c.Write())
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor: nano\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor: nano\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, `github.com:
git_protocol: ssh
@ -37,7 +37,7 @@ func Test_defaultConfig(t *testing.T) {
cfg := NewBlankConfig()
assert.NoError(t, cfg.Write())
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled\nprompt: enabled\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, "", hostsBuf.String())

View file

@ -9,6 +9,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/auth/client"
@ -71,6 +72,10 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
if opts.Hostname == "" {
opts.Hostname = ghinstance.Default()
}
} else {
if !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--with-token required when not running interactively")}
}
}
if cmd.Flags().Changed("hostname") {
@ -202,7 +207,7 @@ func loginRun(opts *LoginOptions) error {
}
if authMode == 0 {
_, err := config.AuthFlowWithConfig(cfg, hostname, "", []string{})
_, err := authflow.AuthFlowWithConfig(cfg, hostname, "", []string{})
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}

View file

@ -49,19 +49,13 @@ func Test_NewCmdLogin(t *testing.T) {
name: "nontty, hostname",
stdinTTY: false,
cli: "--hostname claire.redfield",
wants: LoginOptions{
Hostname: "claire.redfield",
Token: "",
},
wantsErr: true,
},
{
name: "nontty",
stdinTTY: false,
cli: "",
wants: LoginOptions{
Hostname: "",
Token: "",
},
wantsErr: true,
},
{
name: "nontty, with-token, hostname",
@ -109,6 +103,7 @@ func Test_NewCmdLogin(t *testing.T) {
IOStreams: io,
}
io.SetStdoutTTY(true)
io.SetStdinTTY(tt.stdinTTY)
if tt.stdin != "" {
stdin.WriteString(tt.stdin)

View file

@ -48,6 +48,10 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co
# => log out of specified host
`),
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Hostname == "" && !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
}
if runF != nil {
return runF(opts)
}
@ -62,16 +66,8 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co
}
func logoutRun(opts *LogoutOptions) error {
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
hostname := opts.Hostname
if !isTTY && hostname == "" {
return errors.New("--hostname required when not attached to a terminal")
}
showConfirm := isTTY && hostname == ""
cfg, err := opts.Config()
if err != nil {
return err
@ -131,7 +127,7 @@ func logoutRun(opts *LogoutOptions) error {
usernameStr = fmt.Sprintf(" account '%s'", username)
}
if showConfirm {
if opts.IO.CanPrompt() {
var keepGoing bool
err := prompt.SurveyAskOne(&survey.Confirm{
Message: fmt.Sprintf("Are you sure you want to log out of %s%s?", hostname, usernameStr),
@ -152,6 +148,8 @@ func logoutRun(opts *LogoutOptions) error {
return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err)
}
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
if isTTY {
fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n",
utils.GreenCheck(), utils.Bold(hostname), usernameStr)

View file

@ -17,24 +17,40 @@ import (
func Test_NewCmdLogout(t *testing.T) {
tests := []struct {
name string
cli string
wants LogoutOptions
name string
cli string
wants LogoutOptions
wantsErr bool
tty bool
}{
{
name: "with hostname",
name: "tty with hostname",
tty: true,
cli: "--hostname harry.mason",
wants: LogoutOptions{
Hostname: "harry.mason",
},
},
{
name: "no arguments",
name: "tty no arguments",
tty: true,
cli: "",
wants: LogoutOptions{
Hostname: "",
},
},
{
name: "nontty with hostname",
cli: "--hostname harry.mason",
wants: LogoutOptions{
Hostname: "harry.mason",
},
},
{
name: "nontty no arguments",
cli: "",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -42,6 +58,8 @@ func Test_NewCmdLogout(t *testing.T) {
f := &cmdutil.Factory{
IOStreams: io,
}
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
@ -60,6 +78,10 @@ func Test_NewCmdLogout(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
@ -185,11 +207,6 @@ func Test_logoutRun_nontty(t *testing.T) {
wantErr *regexp.Regexp
ghtoken string
}{
{
name: "no arguments",
wantErr: regexp.MustCompile(`hostname required when not`),
opts: &LogoutOptions{},
},
{
name: "hostname, one host",
opts: &LogoutOptions{

View file

@ -1,10 +1,12 @@
package refresh
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -26,7 +28,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
IO: f.IOStreams,
Config: f.Config,
AuthFlow: func(cfg config.Config, hostname string, scopes []string) error {
_, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes)
_, err := authflow.AuthFlowWithConfig(cfg, hostname, "", scopes)
return err
},
}
@ -48,6 +50,17 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
# => open a browser to ensure your authentication credentials have the correct minimum scopes
`),
RunE: func(cmd *cobra.Command, args []string) error {
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
if !isTTY {
return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended")
}
if opts.Hostname == "" && !opts.IO.CanPrompt() {
// here, we know we are attached to a TTY but prompts are disabled
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
}
if runF != nil {
return runF(opts)
}
@ -63,12 +76,6 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
}
func refreshRun(opts *RefreshOptions) error {
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
if !isTTY {
return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended")
}
cfg, err := opts.Config()
if err != nil {
return err

View file

@ -14,34 +14,68 @@ import (
"github.com/stretchr/testify/assert"
)
// TODO prompt cfg test
func Test_NewCmdRefresh(t *testing.T) {
tests := []struct {
name string
cli string
wants RefreshOptions
name string
cli string
wants RefreshOptions
wantsErr bool
tty bool
neverPrompt bool
}{
{
name: "no arguments",
name: "tty no arguments",
tty: true,
wants: RefreshOptions{
Hostname: "",
},
},
{
name: "hostname",
name: "nontty no arguments",
wantsErr: true,
},
{
name: "nontty hostname",
cli: "-h aline.cedrac",
wantsErr: true,
},
{
name: "tty hostname",
tty: true,
cli: "-h aline.cedrac",
wants: RefreshOptions{
Hostname: "aline.cedrac",
},
},
{
name: "one scope",
name: "prompts disabled, no args",
tty: true,
cli: "",
neverPrompt: true,
wantsErr: true,
},
{
name: "prompts disabled, hostname",
tty: true,
cli: "-h aline.cedrac",
neverPrompt: true,
wants: RefreshOptions{
Hostname: "aline.cedrac",
},
},
{
name: "tty one scope",
tty: true,
cli: "--scopes repo:invite",
wants: RefreshOptions{
Scopes: []string{"repo:invite"},
},
},
{
name: "scopes",
name: "tty scopes",
tty: true,
cli: "--scopes repo:invite,read:public_key",
wants: RefreshOptions{
Scopes: []string{"repo:invite", "read:public_key"},
@ -55,6 +89,9 @@ func Test_NewCmdRefresh(t *testing.T) {
f := &cmdutil.Factory{
IOStreams: io,
}
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
io.SetNeverPrompt(tt.neverPrompt)
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
@ -73,11 +110,14 @@ func Test_NewCmdRefresh(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes)
})
}
}
@ -96,12 +136,6 @@ func Test_refreshRun(t *testing.T) {
nontty bool
wantAuthArgs authArgs
}{
{
name: "non tty",
opts: &RefreshOptions{},
nontty: true,
wantErr: regexp.MustCompile(`not attached to a terminal;`),
},
{
name: "no hosts configured",
opts: &RefreshOptions{},

View file

@ -18,6 +18,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
Current respected settings:
- git_protocol: "https" or "ssh". Default is "https".
- editor: if unset, defaults to environment variables.
- prompt: "enabled" or "disabled". Toggles interactive prompting.
`),
}
@ -72,6 +73,8 @@ func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command {
Example: heredoc.Doc(`
$ gh config set editor vim
$ gh config set editor "code --wait"
$ gh config set git_protocol ssh
$ gh config set prompt disabled
`),
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {

View file

@ -1,6 +1,7 @@
package create
import (
"errors"
"fmt"
"net/http"
@ -23,13 +24,14 @@ type CreateOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
RootDirOverride string
RepoOverride string
WebMode bool
Title string
TitleProvided bool
Body string
BodyProvided bool
Title string
Body string
Interactive bool
Assignees []string
Labels []string
@ -59,10 +61,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TitleProvided = cmd.Flags().Changed("title")
opts.BodyProvided = cmd.Flags().Changed("body")
titleProvided := cmd.Flags().Changed("title")
bodyProvided := cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
opts.Interactive = !(titleProvided && bodyProvided)
if opts.Interactive && !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("must provide --title and --body when not running interactively")}
}
if runF != nil {
return runF(opts)
}
@ -94,9 +102,10 @@ func createRun(opts *CreateOptions) error {
}
var nonLegacyTemplateFiles []string
if opts.RepoOverride == "" {
if opts.RootDirOverride != "" {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "ISSUE_TEMPLATE")
} else if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
@ -148,13 +157,7 @@ func createRun(opts *CreateOptions) error {
title := opts.Title
body := opts.Body
interactive := !(opts.TitleProvided && opts.BodyProvided)
if interactive && !isTerminal {
return fmt.Errorf("must provide --title and --body when not attached to a terminal")
}
if interactive {
if opts.Interactive {
var legacyTemplateFile *string
if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {

View file

@ -16,6 +16,7 @@ import (
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
@ -29,6 +30,10 @@ func eq(t *testing.T, got interface{}, expected interface{}) {
}
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
return runCommandWithRootDirOverridden(rt, isTTY, cli, "")
}
func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli string, rootDir string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
@ -47,7 +52,10 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
},
}
cmd := NewCmdCreate(factory, nil)
cmd := NewCmdCreate(factory, func(opts *CreateOptions) error {
opts.RootDirOverride = rootDir
return createRun(opts)
})
argv, err := shlex.Split(cli)
if err != nil {
@ -70,20 +78,12 @@ func TestIssueCreate_nontty_error(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }
`))
_, err := runCommand(http, false, `-t hello`)
if err == nil {
t.Fatal("expected error running command `issue create`")
}
assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error())
assert.Equal(t, "must provide --title and --body when not running interactively", err.Error())
}
func TestIssueCreate(t *testing.T) {
@ -126,6 +126,67 @@ func TestIssueCreate(t *testing.T) {
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_nonLegacyTemplate(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createIssue": { "issue": {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`))
as, teardown := prompt.InitAskStubber()
defer teardown()
as.Stub([]*prompt.QuestionStub{
{
Name: "index",
Value: 1,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "body",
Default: true,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./fixtures/repoWithNonLegacyIssueTemplates")
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "hello")
eq(t, reqBody.Variables.Input.Body, "I have a suggestion for an enhancement")
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_metadata(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)

View file

@ -0,0 +1,9 @@
---
name: Bug report
about: Report a bug or unexpected behavior
title: Bug Report
labels: bug
---
I wanna report a bug

View file

@ -0,0 +1,9 @@
---
name: Submit a request
about: Propose an improvement
title: Enhancement Proposal
labels: enhancement
---
I have a suggestion for an enhancement

View file

@ -83,7 +83,9 @@ func viewRun(opts *ViewOptions) error {
openURL := issue.URL
if opts.WebMode {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL)
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}
if opts.IO.IsStdoutTTY() {

View file

@ -87,7 +87,7 @@ func TestIssueView_web(t *testing.T) {
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")
@ -120,7 +120,7 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) {
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")

227
pkg/cmd/pr/checks/checks.go Normal file
View file

@ -0,0 +1,227 @@
package checks
import (
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ChecksOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Remotes func() (context.Remotes, error)
SelectorArg string
}
func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command {
opts := &ChecksOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Branch: f.Branch,
Remotes: f.Remotes,
BaseRepo: f.BaseRepo,
}
cmd := &cobra.Command{
Use: "checks",
Short: "Show CI status for a single pull request",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
}
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if runF != nil {
return runF(opts)
}
return checksRun(opts)
},
}
return cmd
}
func checksRun(opts *ChecksOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return err
}
if len(pr.Commits.Nodes) == 0 {
return nil
}
rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
if len(rollup) == 0 {
return nil
}
passing := 0
failing := 0
pending := 0
type output struct {
mark string
bucket string
name string
elapsed string
link string
markColor func(string) string
}
outputs := []output{}
for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
mark := "✓"
bucket := "pass"
state := c.State
markColor := utils.Green
if state == "" {
if c.Status == "COMPLETED" {
state = c.Conclusion
} else {
state = c.Status
}
}
switch state {
case "SUCCESS", "NEUTRAL", "SKIPPED":
passing++
case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
mark = "X"
markColor = utils.Red
failing++
bucket = "fail"
case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE":
mark = "-"
markColor = utils.Yellow
pending++
bucket = "pending"
default:
panic(fmt.Errorf("unsupported status: %q", state))
}
elapsed := ""
zeroTime := time.Time{}
if c.StartedAt != zeroTime && c.CompletedAt != zeroTime {
e := c.CompletedAt.Sub(c.StartedAt)
if e > 0 {
elapsed = e.String()
}
}
link := c.DetailsURL
if link == "" {
link = c.TargetURL
}
name := c.Name
if name == "" {
name = c.Context
}
outputs = append(outputs, output{mark, bucket, name, elapsed, link, markColor})
}
sort.Slice(outputs, func(i, j int) bool {
b0 := outputs[i].bucket
n0 := outputs[i].name
l0 := outputs[i].link
b1 := outputs[j].bucket
n1 := outputs[j].name
l1 := outputs[j].link
if b0 == b1 {
if n0 == n1 {
return l0 < l1
} else {
return n0 < n1
}
}
return (b0 == "fail") || (b0 == "pending" && b1 == "success")
})
tp := utils.NewTablePrinter(opts.IO)
for _, o := range outputs {
if opts.IO.IsStdoutTTY() {
tp.AddField(o.mark, nil, o.markColor)
tp.AddField(o.name, nil, nil)
tp.AddField(o.elapsed, nil, nil)
tp.AddField(o.link, nil, nil)
} else {
tp.AddField(o.name, nil, nil)
tp.AddField(o.bucket, nil, nil)
if o.elapsed == "" {
tp.AddField("0", nil, nil)
} else {
tp.AddField(o.elapsed, nil, nil)
}
tp.AddField(o.link, nil, nil)
}
tp.EndRow()
}
summary := ""
if failing+passing+pending > 0 {
if failing > 0 {
summary = "Some checks were not successful"
} else if pending > 0 {
summary = "Some checks are still pending"
} else {
summary = "All checks were successful"
}
tallies := fmt.Sprintf(
"%d failing, %d successful, and %d pending checks",
failing, passing, pending)
summary = fmt.Sprintf("%s\n%s", utils.Bold(summary), tallies)
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintln(opts.IO.Out, summary)
fmt.Fprintln(opts.IO.Out)
}
err = tp.Render()
if err != nil {
return err
}
if failing+pending > 0 {
return cmdutil.SilentError
}
return nil
}

View file

@ -0,0 +1,211 @@
package checks
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdChecks(t *testing.T) {
tests := []struct {
name string
cli string
wants ChecksOptions
}{
{
name: "no arguments",
cli: "",
wants: ChecksOptions{},
},
{
name: "pr argument",
cli: "1234",
wants: ChecksOptions{
SelectorArg: "1234",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ChecksOptions
cmd := NewCmdChecks(f, func(opts *ChecksOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, tt.wants.SelectorArg, gotOpts.SelectorArg)
})
}
}
func Test_checksRun(t *testing.T) {
tests := []struct {
name string
fixture string
stubs func(*httpmock.Registry)
wantOut string
nontty bool
wantErr bool
}{
{
name: "no commits",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.JSONResponse(
bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`)))
},
},
{
name: "no checks",
stubs: func(reg *httpmock.Registry) {
reg.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} }
} } }
`))
},
},
{
name: "some failing",
fixture: "./fixtures/someFailing.json",
wantOut: "Some checks were not successful\n1 failing, 1 successful, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n- slow tests 1m26s sweet link\n",
wantErr: true,
},
{
name: "some pending",
fixture: "./fixtures/somePending.json",
wantOut: "Some checks are still pending\n0 failing, 2 successful, and 1 pending checks\n\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n- slow tests 1m26s sweet link\n",
wantErr: true,
},
{
name: "all passing",
fixture: "./fixtures/allPassing.json",
wantOut: "All checks were successful\n0 failing, 3 successful, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
},
{
name: "with statuses",
fixture: "./fixtures/withStatuses.json",
wantOut: "Some checks were not successful\n1 failing, 2 successful, and 0 pending checks\n\nX a status sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
wantErr: true,
},
{
name: "no commits",
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.JSONResponse(
bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`)))
},
},
{
name: "no checks",
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} }
} } }
`))
},
},
{
name: "some failing",
nontty: true,
fixture: "./fixtures/someFailing.json",
wantOut: "sad tests\tfail\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n",
wantErr: true,
},
{
name: "some pending",
nontty: true,
fixture: "./fixtures/somePending.json",
wantOut: "cool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n",
wantErr: true,
},
{
name: "all passing",
nontty: true,
fixture: "./fixtures/allPassing.json",
wantOut: "awesome tests\tpass\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n",
},
{
name: "with statuses",
nontty: true,
fixture: "./fixtures/withStatuses.json",
wantOut: "a status\tfail\t0\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
opts := &ChecksOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
SelectorArg: "123",
}
reg := &httpmock.Registry{}
if tt.stubs != nil {
tt.stubs(reg)
} else if tt.fixture != "" {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture))
} else {
panic("need either stubs or fixture key")
}
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
err := checksRun(opts)
if tt.wantErr {
assert.Equal(t, "SilentError", err.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}

View file

@ -0,0 +1,41 @@
{ "data": { "repository": { "pullRequest": {
"number": 123,
"commits": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "rad tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "awesome tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
}
]
}
}
}
}
]}
} } } }

View file

@ -0,0 +1,41 @@
{ "data": { "repository": { "pullRequest": {
"number": 123,
"commits": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "FAILURE",
"status": "COMPLETED",
"name": "sad tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "",
"status": "IN_PROGRESS",
"name": "slow tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
}
]
}
}
}
}
]}
} } } }

View file

@ -0,0 +1,41 @@
{ "data": { "repository": { "pullRequest": {
"number": 123,
"commits": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "rad tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "",
"status": "IN_PROGRESS",
"name": "slow tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
}
]
}
}
}
}
]}
} } } }

View file

@ -0,0 +1,38 @@
{ "data": { "repository": { "pullRequest": {
"number": 123,
"commits": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "rad tests",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link"
},
{
"state": "FAILURE",
"name": "a status",
"targetUrl": "sweet link"
}
]
}
}
}
}
]}
} } } }

View file

@ -28,17 +28,18 @@ type CreateOptions struct {
Remotes func() (context.Remotes, error)
Branch func() (string, error)
RepoOverride string
Interactive bool
RootDirOverride string
RepoOverride string
Autofill bool
WebMode bool
IsDraft bool
Title string
TitleProvided bool
Body string
BodyProvided bool
BaseBranch string
IsDraft bool
Title string
Body string
BaseBranch string
Reviewers []string
Assignees []string
@ -69,13 +70,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
`),
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
opts.TitleProvided = cmd.Flags().Changed("title")
opts.BodyProvided = cmd.Flags().Changed("body")
titleProvided := cmd.Flags().Changed("title")
bodyProvided := cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
if !isTerminal && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
return errors.New("--title or --fill required when not attached to a terminal")
opts.Interactive = !(titleProvided && bodyProvided)
if !opts.IO.CanPrompt() && !opts.WebMode && !titleProvided && !opts.Autofill {
return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")}
}
if opts.IsDraft && opts.WebMode {
@ -241,13 +243,14 @@ func createRun(opts *CreateOptions) error {
Milestones: milestoneTitles,
}
interactive := isTerminal && !(opts.TitleProvided && opts.BodyProvided)
if !opts.WebMode && !opts.Autofill && interactive {
if !opts.WebMode && !opts.Autofill && opts.Interactive {
var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
if opts.RootDirOverride != "" {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
} else if rootDir, err := git.ToplevelDir(); err == nil {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
}

View file

@ -5,6 +5,8 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"os"
"path"
"reflect"
"strings"
"testing"
@ -31,6 +33,10 @@ func eq(t *testing.T, got interface{}, expected interface{}) {
}
func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) {
return runCommandWithRootDirOverridden(rt, remotes, branch, isTTY, cli, "")
}
func runCommandWithRootDirOverridden(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string, rootDir string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
@ -60,7 +66,10 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, is
},
}
cmd := NewCmdCreate(factory, nil)
cmd := NewCmdCreate(factory, func(opts *CreateOptions) error {
opts.RootDirOverride = rootDir
return createRun(opts)
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(cli)
@ -126,7 +135,7 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) {
t.Fatal("expected error")
}
assert.Equal(t, "--title or --fill required when not attached to a terminal", err.Error())
assert.Equal(t, "--title or --fill required when not running interactively", err.Error())
assert.Equal(t, "", output.String())
}
@ -239,6 +248,80 @@ func TestPRCreate(t *testing.T) {
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_nonLegacyTemplate(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
as, teardown := prompt.InitAskStubber()
defer teardown()
as.Stub([]*prompt.QuestionStub{
{
Name: "index",
Value: 0,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "body",
Default: true,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title"`, "./fixtures/repoWithNonLegacyPRTemplates")
require.NoError(t, err)
bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
BaseRefName string
HeadRefName string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "my title")
eq(t, reqBody.Variables.Input.Body, "- commit 1\n- commit 0\n\nFixes a bug and Closes an issue")
eq(t, reqBody.Variables.Input.BaseRefName, "master")
eq(t, reqBody.Variables.Input.HeadRefName, "feature")
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_metadata(t *testing.T) {
http := initFakeHTTP()
@ -770,6 +853,77 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_survey_defaults_monocommit_template(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE")))
http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.Register(httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["repositoryId"], "REPOID")
eq(t, inputs["title"], "the sky above the port")
eq(t, inputs["body"], "was the color of a television\n\n... turned to a dead channel")
eq(t, inputs["baseRefName"], "master")
eq(t, inputs["headRefName"], "feature")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
tmpdir, err := ioutil.TempDir("", "gh-cli")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpdir)
templateFp := path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE.md")
_ = os.MkdirAll(path.Dir(templateFp), 0700)
_ = ioutil.WriteFile(templateFp, []byte("... turned to a dead channel"), 0700)
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,the sky above the port") // git log
cs.Stub("was the color of a television") // git show
cs.Stub(tmpdir) // git rev-parse
cs.Stub("") // git push
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.Stub([]*prompt.QuestionStub{
{
Name: "title",
Default: true,
},
{
Name: "body",
Default: true,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
output, err := runCommand(http, nil, "feature", true, ``)
eq(t, err, nil)
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_survey_autofill_nontty(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)

View file

@ -0,0 +1,7 @@
---
name: "Bug fix"
about: Fix a bug
---
Fixes a bug and Closes an issue

View file

@ -86,8 +86,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
methodFlags++
}
if methodFlags == 0 {
if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() {
return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not attached to a terminal")}
if !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not running interactively")}
}
opts.InteractiveMode = true
} else if methodFlags > 1 {

View file

@ -53,7 +53,7 @@ func Test_NewCmdMerge(t *testing.T) {
name: "insufficient flags in non-interactive mode",
args: "123",
isTTY: false,
wantErr: "--merge, --rebase, or --squash required when not attached to a terminal",
wantErr: "--merge, --rebase, or --squash required when not running interactively",
},
{
name: "multiple merge methods",
@ -189,6 +189,7 @@ func TestPrMerge(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -234,6 +235,7 @@ func TestPrMerge_nontty(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -276,6 +278,7 @@ func TestPrMerge_withRepoFlag(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -310,6 +313,7 @@ func TestPrMerge_deleteBranch(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "PR_10", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -344,6 +348,7 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "PR_10", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -376,6 +381,7 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "PR_10", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -421,6 +427,7 @@ func TestPrMerge_rebase(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "REBASE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -465,6 +472,7 @@ func TestPrMerge_squash(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "SQUASH", input["mergeMethod"].(string))
assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string))
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
@ -533,6 +541,7 @@ func TestPRMerge_interactive(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),

View file

@ -3,6 +3,7 @@ package pr
import (
"github.com/MakeNowJust/heredoc"
cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout"
cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks"
cmdClose "github.com/cli/cli/pkg/cmd/pr/close"
cmdCreate "github.com/cli/cli/pkg/cmd/pr/create"
cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff"
@ -51,6 +52,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdReview.NewCmdReview(f, nil))
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil))
return cmd
}

View file

@ -104,8 +104,8 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
}
if found == 0 && opts.Body == "" {
if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() {
return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when not attached to a tty")}
if !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when not running interactively")}
}
opts.InteractiveMode = true
} else if found == 0 && opts.Body != "" {

View file

@ -60,7 +60,7 @@ func Test_NewCmdReview(t *testing.T) {
name: "no arguments in non-interactive mode",
args: "",
isTTY: false,
wantErr: "--approve, --request-changes, or --comment required when not attached to a tty",
wantErr: "--approve, --request-changes, or --comment required when not running interactively",
},
{
name: "mutually exclusive review types",

View file

@ -2,6 +2,7 @@ package shared
import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
@ -154,18 +155,26 @@ func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState *
templateContents := ""
if providedBody == "" {
issueState.Body = defs.Body
if len(nonLegacyTemplatePaths) > 0 {
var err error
templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type)
if err != nil {
return err
}
issueState.Body = templateContents
} else if legacyTemplatePath != nil {
templateContents = string(githubtemplate.ExtractContents(*legacyTemplatePath))
issueState.Body = templateContents
} else {
issueState.Body = defs.Body
}
if templateContents != "" {
if issueState.Body != "" {
// prevent excessive newlines between default body and template
issueState.Body = strings.TrimRight(issueState.Body, "\n")
issueState.Body += "\n\n"
}
issueState.Body += templateContents
}
}

View file

@ -94,7 +94,7 @@ func viewRun(opts *ViewOptions) error {
if opts.BrowserMode {
if connectedToTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL)
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}

View file

@ -507,7 +507,7 @@ func TestPRView_web_currentBranch(t *testing.T) {
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pull/10 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")

View file

@ -0,0 +1,365 @@
package create
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/pkg/text"
"github.com/spf13/cobra"
)
type CreateOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
TagName string
Target string
Name string
Body string
BodyProvided bool
Draft bool
Prerelease bool
Assets []*shared.AssetForUpload
// for interactive flow
SubmitAction string
// for interactive flow
ReleaseNotesAction string
// the value from the --repo flag
RepoOverride string
// maximum number of simultaneous uploads
Concurrency int
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
var notesFile string
cmd := &cobra.Command{
Use: "create <tag> [<files>...]",
Short: "Create a new release",
Long: heredoc.Doc(`
Create a new GitHub Release for a repository.
A list of asset files may be given to upload to the new release. To define a
display label for an asset, append text starting with '#' after the file name.
`),
Example: heredoc.Doc(`
# use release notes from a file
$ gh release create v1.2.3 -F changelog.md
# upload a release asset with a display label
$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
`),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
opts.TagName = args[0]
var err error
opts.Assets, err = shared.AssetsFromArgs(args[1:])
if err != nil {
return err
}
opts.Concurrency = 5
opts.BodyProvided = cmd.Flags().Changed("notes")
if notesFile != "" {
var b []byte
if notesFile == "-" {
b, err = ioutil.ReadAll(opts.IO.In)
} else {
b, err = ioutil.ReadFile(notesFile)
}
if err != nil {
return err
}
opts.Body = string(b)
opts.BodyProvided = true
}
if runF != nil {
return runF(opts)
}
return createRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it")
cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or commit SHA (default: main branch)")
cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title")
cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes")
cmd.Flags().StringVarP(&notesFile, "notes-file", "F", "", "Read release notes from `file`")
return cmd
}
func createRun(opts *CreateOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
if !opts.BodyProvided && opts.IO.CanPrompt() {
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
var tagDescription string
var generatedChangelog string
if opts.RepoOverride == "" {
headRef := opts.TagName
tagDescription, _ = gitTagInfo(opts.TagName)
if tagDescription == "" {
if opts.Target != "" {
// TODO: use the remote-tracking version of the branch ref
headRef = opts.Target
} else {
headRef = "HEAD"
}
}
if prevTag, err := detectPreviousTag(headRef); err == nil {
commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef))
generatedChangelog = generateChangelog(commits)
}
}
editorOptions := []string{"Write my own"}
if generatedChangelog != "" {
editorOptions = append(editorOptions, "Write using commit log as template")
}
if tagDescription != "" {
editorOptions = append(editorOptions, "Write using git tag message as template")
}
editorOptions = append(editorOptions, "Leave blank")
qs := []*survey.Question{
{
Name: "name",
Prompt: &survey.Input{
Message: "Title (optional)",
Default: opts.Name,
},
},
{
Name: "releaseNotesAction",
Prompt: &survey.Select{
Message: "Release notes",
Options: editorOptions,
},
},
}
err = prompt.SurveyAsk(qs, opts)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
var openEditor bool
var editorContents string
switch opts.ReleaseNotesAction {
case "Write my own":
openEditor = true
case "Write using commit log as template":
openEditor = true
editorContents = generatedChangelog
case "Write using git tag message as template":
openEditor = true
editorContents = tagDescription
case "Leave blank":
openEditor = false
default:
return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction)
}
if openEditor {
// TODO: consider using iostreams here
text, err := surveyext.Edit(editorCommand, "*.md", editorContents, os.Stdin, os.Stdout, os.Stderr, nil)
if err != nil {
return err
}
opts.Body = text
}
qs = []*survey.Question{
{
Name: "prerelease",
Prompt: &survey.Confirm{
Message: "Is this a prerelease?",
Default: opts.Prerelease,
},
},
{
Name: "submitAction",
Prompt: &survey.Select{
Message: "Submit?",
Options: []string{
"Publish release",
"Save as draft",
"Cancel",
},
},
},
}
err = prompt.SurveyAsk(qs, opts)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
switch opts.SubmitAction {
case "Publish release":
opts.Draft = false
case "Save as draft":
opts.Draft = true
case "Cancel":
return cmdutil.SilentError
default:
return fmt.Errorf("invalid action: %v", opts.SubmitAction)
}
}
params := map[string]interface{}{
"tag_name": opts.TagName,
"draft": opts.Draft,
"prerelease": opts.Prerelease,
"name": opts.Name,
"body": opts.Body,
}
if opts.Target != "" {
params["target_commitish"] = opts.Target
}
hasAssets := len(opts.Assets) > 0
// Avoid publishing the release until all assets have finished uploading
if hasAssets {
params["draft"] = true
}
newRelease, err := createRelease(httpClient, baseRepo, params)
if err != nil {
return err
}
if hasAssets {
uploadURL := newRelease.UploadURL
if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
uploadURL = uploadURL[:idx]
}
opts.IO.StartProgressIndicator()
err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if !opts.Draft {
rel, err := publishRelease(httpClient, newRelease.APIURL)
if err != nil {
return err
}
newRelease = rel
}
}
fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL)
return nil
}
func gitTagInfo(tagName string) (string, error) {
cmd := exec.Command("git", "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
b, err := run.PrepareCmd(cmd).Output()
return string(b), err
}
func detectPreviousTag(headRef string) (string, error) {
cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
b, err := run.PrepareCmd(cmd).Output()
return strings.TrimSpace(string(b)), err
}
type logEntry struct {
Subject string
Body string
}
func changelogForRange(refRange string) ([]logEntry, error) {
cmd := exec.Command("git", "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
b, err := run.PrepareCmd(cmd).Output()
if err != nil {
return nil, err
}
var entries []logEntry
for _, cb := range bytes.Split(b, []byte{'\000'}) {
c := strings.ReplaceAll(string(cb), "\r\n", "\n")
c = strings.TrimPrefix(c, "\n")
if len(c) == 0 {
continue
}
parts := strings.SplitN(c, "\n\n", 2)
var body string
subject := strings.ReplaceAll(parts[0], "\n", " ")
if len(parts) > 1 {
body = parts[1]
}
entries = append(entries, logEntry{
Subject: subject,
Body: body,
})
}
return entries, nil
}
func generateChangelog(commits []logEntry) string {
var parts []string
for _, c := range commits {
// TODO: consider rendering "Merge pull request #123 from owner/branch" differently
parts = append(parts, fmt.Sprintf("* %s", c.Subject))
if c.Body != "" {
parts = append(parts, text.Indent(c.Body, " "))
}
}
return strings.Join(parts, "\n\n")
}

View file

@ -0,0 +1,383 @@
package create
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewCmdCreate(t *testing.T) {
tempDir := t.TempDir()
tf, err := ioutil.TempFile(tempDir, "release-create")
require.NoError(t, err)
fmt.Fprint(tf, "MY NOTES")
tf.Close()
af1, err := os.Create(filepath.Join(tempDir, "windows.zip"))
require.NoError(t, err)
af1.Close()
af2, err := os.Create(filepath.Join(tempDir, "linux.tgz"))
require.NoError(t, err)
af2.Close()
tests := []struct {
name string
args string
isTTY bool
stdin string
want CreateOptions
wantErr string
}{
{
name: "only tag name",
args: "v1.2.3",
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
},
},
{
name: "asset files",
args: fmt.Sprintf("v1.2.3 '%s' '%s#Linux build'", af1.Name(), af2.Name()),
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload{
{
Name: "windows.zip",
Label: "",
},
{
Name: "linux.tgz",
Label: "Linux build",
},
},
},
},
{
name: "provide title and body",
args: "v1.2.3 -t mytitle -n mynotes",
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "mytitle",
Body: "mynotes",
BodyProvided: true,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
},
},
{
name: "notes from file",
args: fmt.Sprintf(`v1.2.3 -F '%s'`, tf.Name()),
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "MY NOTES",
BodyProvided: true,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
},
},
{
name: "notes from stdin",
args: "v1.2.3 -F -",
isTTY: true,
stdin: "MY NOTES",
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "MY NOTES",
BodyProvided: true,
Draft: false,
Prerelease: false,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
},
},
{
name: "set draft and prerelease",
args: "v1.2.3 -d -p",
isTTY: true,
want: CreateOptions{
TagName: "v1.2.3",
Target: "",
Name: "",
Body: "",
BodyProvided: false,
Draft: true,
Prerelease: true,
RepoOverride: "",
Concurrency: 5,
Assets: []*shared.AssetForUpload(nil),
},
},
{
name: "no arguments",
args: "",
isTTY: true,
wantErr: "requires at least 1 arg(s), only received 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
if tt.stdin == "" {
io.SetStdinTTY(tt.isTTY)
} else {
io.SetStdinTTY(false)
fmt.Fprint(stdin, tt.stdin)
}
io.SetStdoutTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
f := &cmdutil.Factory{
IOStreams: io,
}
var opts *CreateOptions
cmd := NewCmdCreate(f, func(o *CreateOptions) error {
opts = o
return nil
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(tt.args)
require.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.TagName, opts.TagName)
assert.Equal(t, tt.want.Target, opts.Target)
assert.Equal(t, tt.want.Name, opts.Name)
assert.Equal(t, tt.want.Body, opts.Body)
assert.Equal(t, tt.want.BodyProvided, opts.BodyProvided)
assert.Equal(t, tt.want.Draft, opts.Draft)
assert.Equal(t, tt.want.Prerelease, opts.Prerelease)
assert.Equal(t, tt.want.Concurrency, opts.Concurrency)
assert.Equal(t, tt.want.RepoOverride, opts.RepoOverride)
require.Equal(t, len(tt.want.Assets), len(opts.Assets))
for i := range tt.want.Assets {
assert.Equal(t, tt.want.Assets[i].Name, opts.Assets[i].Name)
assert.Equal(t, tt.want.Assets[i].Label, opts.Assets[i].Label)
}
})
}
}
func Test_createRun(t *testing.T) {
tests := []struct {
name string
isTTY bool
opts CreateOptions
wantParams interface{}
wantErr string
wantStdout string
wantStderr string
}{
{
name: "create a release",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "The Big 1.2",
Body: "* Fixed bugs",
BodyProvided: true,
Target: "",
},
wantParams: map[string]interface{}{
"tag_name": "v1.2.3",
"name": "The Big 1.2",
"body": "* Fixed bugs",
"draft": false,
"prerelease": false,
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "with target commitish",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "",
Body: "",
BodyProvided: true,
Target: "main",
},
wantParams: map[string]interface{}{
"tag_name": "v1.2.3",
"name": "",
"body": "",
"draft": false,
"prerelease": false,
"target_commitish": "main",
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "as draft",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "",
Body: "",
BodyProvided: true,
Draft: true,
Target: "",
},
wantParams: map[string]interface{}{
"tag_name": "v1.2.3",
"name": "",
"body": "",
"draft": true,
"prerelease": false,
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
{
name: "publish after uploading files",
isTTY: true,
opts: CreateOptions{
TagName: "v1.2.3",
Name: "",
Body: "",
BodyProvided: true,
Draft: false,
Target: "",
Assets: []*shared.AssetForUpload{
{
Name: "ball.tgz",
Open: func() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewBufferString(`TARBALL`)), nil
},
},
},
Concurrency: 1,
},
wantParams: map[string]interface{}{
"tag_name": "v1.2.3",
"name": "",
"body": "",
"draft": true,
"prerelease": false,
},
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3-final\n",
wantStderr: ``,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
fakeHTTP := &httpmock.Registry{}
fakeHTTP.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.StatusStringResponse(201, `{
"url": "https://api.github.com/releases/123",
"upload_url": "https://api.github.com/assets/upload",
"html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3"
}`))
fakeHTTP.Register(httpmock.REST("POST", "assets/upload"), httpmock.StatusStringResponse(201, `{}`))
fakeHTTP.Register(httpmock.REST("PATCH", "releases/123"), httpmock.StatusStringResponse(201, `{
"html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3-final"
}`))
tt.opts.IO = io
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: fakeHTTP}, nil
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := createRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
bb, err := ioutil.ReadAll(fakeHTTP.Requests[0].Body)
require.NoError(t, err)
var params interface{}
err = json.Unmarshal(bb, &params)
require.NoError(t, err)
assert.Equal(t, tt.wantParams, params)
if len(tt.opts.Assets) > 0 {
q := fakeHTTP.Requests[1].URL.Query()
assert.Equal(t, tt.opts.Assets[0].Name, q.Get("name"))
assert.Equal(t, tt.opts.Assets[0].Label, q.Get("label"))
bb, err := ioutil.ReadAll(fakeHTTP.Requests[2].Body)
require.NoError(t, err)
var updateParams interface{}
err = json.Unmarshal(bb, &updateParams)
require.NoError(t, err)
assert.Equal(t, map[string]interface{}{"draft": false}, updateParams)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,78 @@
package create
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
)
func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) {
bodyBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("repos/%s/%s/releases", repo.RepoOwner(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newRelease shared.Release
err = json.Unmarshal(b, &newRelease)
return &newRelease, err
}
func publishRelease(httpClient *http.Client, releaseURL string) (*shared.Release, error) {
req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBufferString(`{"draft":false}`))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release shared.Release
err = json.Unmarshal(b, &release)
return &release, err
}

View file

@ -0,0 +1,119 @@
package delete
import (
"fmt"
"net/http"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/spf13/cobra"
)
type DeleteOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
SkipConfirm bool
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
opts := &DeleteOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "delete <tag>",
Short: "Delete a release",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TagName = args[0]
if runF != nil {
return runF(opts)
}
return deleteRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.SkipConfirm, "yes", "y", false, "Skip the confirmation prompt")
return cmd
}
func deleteRun(opts *DeleteOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
if !opts.SkipConfirm && opts.IO.CanPrompt() {
var confirmed bool
err := prompt.SurveyAskOne(&survey.Confirm{
Message: fmt.Sprintf("Delete release %s in %s?", release.TagName, ghrepo.FullName(baseRepo)),
Default: true,
}, &confirmed)
if err != nil {
return err
}
if !confirmed {
return cmdutil.SilentError
}
}
err = deleteRelease(httpClient, release.APIURL)
if err != nil {
return err
}
if !opts.IO.IsStdoutTTY() || !opts.IO.IsStderrTTY() {
return nil
}
iofmt := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted release %s\n", iofmt.SuccessIcon(), release.TagName)
if !release.IsDraft {
fmt.Fprintf(opts.IO.ErrOut, "%s Note that the %s git tag still remains in the repository\n", iofmt.WarningIcon(), release.TagName)
}
return nil
}
func deleteRelease(httpClient *http.Client, releaseURL string) error {
req, err := http.NewRequest("DELETE", releaseURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
return nil
}

View file

@ -0,0 +1,160 @@
package delete
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewCmdDelete(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want DeleteOptions
wantErr string
}{
{
name: "version argument",
args: "v1.2.3",
isTTY: true,
want: DeleteOptions{
TagName: "v1.2.3",
SkipConfirm: false,
},
},
{
name: "skip confirm",
args: "v1.2.3 -y",
isTTY: true,
want: DeleteOptions{
TagName: "v1.2.3",
SkipConfirm: true,
},
},
{
name: "no arguments",
args: "",
isTTY: true,
wantErr: "accepts 1 arg(s), received 0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
f := &cmdutil.Factory{
IOStreams: io,
}
var opts *DeleteOptions
cmd := NewCmdDelete(f, func(o *DeleteOptions) error {
opts = o
return nil
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(tt.args)
require.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.TagName, opts.TagName)
assert.Equal(t, tt.want.SkipConfirm, opts.SkipConfirm)
})
}
}
func Test_deleteRun(t *testing.T) {
tests := []struct {
name string
isTTY bool
opts DeleteOptions
wantErr string
wantStdout string
wantStderr string
}{
{
name: "skipping confirmation",
isTTY: true,
opts: DeleteOptions{
TagName: "v1.2.3",
SkipConfirm: true,
},
wantStdout: ``,
wantStderr: heredoc.Doc(`
Deleted release v1.2.3
! Note that the v1.2.3 git tag still remains in the repository
`),
},
{
name: "non-interactive",
isTTY: false,
opts: DeleteOptions{
TagName: "v1.2.3",
SkipConfirm: false,
},
wantStdout: ``,
wantStderr: ``,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
fakeHTTP := &httpmock.Registry{}
fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StringResponse(`{
"tag_name": "v1.2.3",
"draft": false,
"url": "https://api.github.com/repos/OWNER/REPO/releases/23456"
}`))
fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/releases/23456"), httpmock.StatusStringResponse(204, ""))
tt.opts.IO = io
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: fakeHTTP}, nil
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := deleteRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,208 @@
package download
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type DownloadOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
FilePatterns []string
Destination string
// maximum number of simultaneous downloads
Concurrency int
}
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
opts := &DownloadOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "download [<tag>]",
Short: "Download release assets",
Long: heredoc.Doc(`
Download assets from a GitHub release.
Without an explicit tag name argument, assets are downloaded from the
latest release in the project. In this case, '--pattern' is required.
`),
Example: heredoc.Doc(`
# download all assets from a specific release
$ gh release download v1.2.3
# download only Debian packages for the latest release
$ gh release download --pattern '*.deb'
# specify multiple file patterns
$ gh release download -p '*.deb' -p '*.rpm'
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) == 0 {
if len(opts.FilePatterns) == 0 {
return &cmdutil.FlagError{Err: errors.New("the '--pattern' flag is required when downloading the latest release")}
}
} else {
opts.TagName = args[0]
}
opts.Concurrency = 5
if runF != nil {
return runF(opts)
}
return downloadRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The directory to download files into")
cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern")
return cmd
}
func downloadRun(opts *DownloadOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
var release *shared.Release
if opts.TagName == "" {
release, err = shared.FetchLatestRelease(httpClient, baseRepo)
if err != nil {
return err
}
} else {
release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
}
var toDownload []shared.ReleaseAsset
for _, a := range release.Assets {
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
continue
}
toDownload = append(toDownload, a)
}
if len(toDownload) == 0 {
if len(release.Assets) > 0 {
return errors.New("no assets match the file pattern")
}
return errors.New("no assets to download")
}
if opts.Destination != "." {
err := os.MkdirAll(opts.Destination, 0755)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = downloadAssets(httpClient, toDownload, opts.Destination, opts.Concurrency)
opts.IO.StopProgressIndicator()
return err
}
func matchAny(patterns []string, name string) bool {
for _, p := range patterns {
if isMatch, err := filepath.Match(p, name); err == nil && isMatch {
return true
}
}
return false
}
func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, destDir string, numWorkers int) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
jobs := make(chan shared.ReleaseAsset, len(toDownload))
results := make(chan error, len(toDownload))
if len(toDownload) < numWorkers {
numWorkers = len(toDownload)
}
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- downloadAsset(httpClient, a.APIURL, filepath.Join(destDir, a.Name))
}
}()
}
for _, a := range toDownload {
jobs <- a
}
close(jobs)
var downloadError error
for i := 0; i < len(toDownload); i++ {
if err := <-results; err != nil {
downloadError = err
}
}
return downloadError
}
func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) error {
req, err := http.NewRequest("GET", assetURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/octet-stream")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
f, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}

View file

@ -0,0 +1,254 @@
package download
import (
"bytes"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewCmdDownload(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want DownloadOptions
wantErr string
}{
{
name: "version argument",
args: "v1.2.3",
isTTY: true,
want: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string(nil),
Destination: ".",
Concurrency: 5,
},
},
{
name: "version and file pattern",
args: "v1.2.3 -p *.tgz",
isTTY: true,
want: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string{"*.tgz"},
Destination: ".",
Concurrency: 5,
},
},
{
name: "multiple file patterns",
args: "v1.2.3 -p 1 -p 2,3",
isTTY: true,
want: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string{"1", "2,3"},
Destination: ".",
Concurrency: 5,
},
},
{
name: "version and destination",
args: "v1.2.3 -D tmp/assets",
isTTY: true,
want: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string(nil),
Destination: "tmp/assets",
Concurrency: 5,
},
},
{
name: "download latest",
args: "-p *",
isTTY: true,
want: DownloadOptions{
TagName: "",
FilePatterns: []string{"*"},
Destination: ".",
Concurrency: 5,
},
},
{
name: "no arguments",
args: "",
isTTY: true,
wantErr: "the '--pattern' flag is required when downloading the latest release",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
f := &cmdutil.Factory{
IOStreams: io,
}
var opts *DownloadOptions
cmd := NewCmdDownload(f, func(o *DownloadOptions) error {
opts = o
return nil
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(tt.args)
require.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.TagName, opts.TagName)
assert.Equal(t, tt.want.FilePatterns, opts.FilePatterns)
assert.Equal(t, tt.want.Destination, opts.Destination)
assert.Equal(t, tt.want.Concurrency, opts.Concurrency)
})
}
}
func Test_downloadRun(t *testing.T) {
tests := []struct {
name string
isTTY bool
opts DownloadOptions
wantErr string
wantStdout string
wantStderr string
wantFiles []string
}{
{
name: "download all assets",
isTTY: true,
opts: DownloadOptions{
TagName: "v1.2.3",
Destination: ".",
Concurrency: 2,
},
wantStdout: ``,
wantStderr: ``,
wantFiles: []string{
"linux.tgz",
"windows-32bit.zip",
"windows-64bit.zip",
},
},
{
name: "download assets matching pattern into destination directory",
isTTY: true,
opts: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string{"windows-*.zip"},
Destination: "tmp/assets",
Concurrency: 2,
},
wantStdout: ``,
wantStderr: ``,
wantFiles: []string{
"tmp/assets/windows-32bit.zip",
"tmp/assets/windows-64bit.zip",
},
},
{
name: "no match for pattern",
isTTY: true,
opts: DownloadOptions{
TagName: "v1.2.3",
FilePatterns: []string{"linux*.zip"},
Destination: ".",
Concurrency: 2,
},
wantStdout: ``,
wantStderr: ``,
wantErr: "no assets match the file pattern",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
tt.opts.Destination = filepath.Join(tempDir, tt.opts.Destination)
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
fakeHTTP := &httpmock.Registry{}
fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StringResponse(`{
"assets": [
{ "name": "windows-32bit.zip", "size": 12,
"url": "https://api.github.com/assets/1234" },
{ "name": "windows-64bit.zip", "size": 34,
"url": "https://api.github.com/assets/3456" },
{ "name": "linux.tgz", "size": 56,
"url": "https://api.github.com/assets/5678" }
]
}`))
fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`))
fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`))
tt.opts.IO = io
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: fakeHTTP}, nil
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := downloadRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, "application/octet-stream", fakeHTTP.Requests[1].Header.Get("Accept"))
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
downloadedFiles, err := listFiles(tempDir)
require.NoError(t, err)
assert.Equal(t, tt.wantFiles, downloadedFiles)
})
}
}
func listFiles(dir string) ([]string, error) {
var files []string
err := filepath.Walk(dir, func(p string, f os.FileInfo, err error) error {
if !f.IsDir() {
rp, err := filepath.Rel(dir, p)
if err != nil {
return err
}
files = append(files, filepath.ToSlash(rp))
}
return err
})
return files, err
}

View file

@ -0,0 +1,72 @@
package list
import (
"context"
"net/http"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Release struct {
Name string
TagName string
IsDraft bool
IsPrerelease bool
CreatedAt time.Time
PublishedAt time.Time
}
func fetchReleases(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]Release, error) {
var query struct {
Repository struct {
Releases struct {
Nodes []Release
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"releases(first: $perPage, orderBy: {field: CREATED_AT, direction: DESC}, after: $endCursor)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
perPage := limit
if limit > 100 {
perPage = 100
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient)
var releases []Release
loop:
for {
err := gql.QueryNamed(context.Background(), "RepositoryReleaseList", &query, variables)
if err != nil {
return nil, err
}
for _, r := range query.Repository.Releases.Nodes {
releases = append(releases, r)
if len(releases) == limit {
break loop
}
}
if !query.Repository.Releases.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Releases.PageInfo.EndCursor)
}
return releases, nil
}

View file

@ -0,0 +1,115 @@
package list
import (
"fmt"
"net/http"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ListOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
LimitResults int
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "list",
Short: "List releases in a repository",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of items to fetch")
return cmd
}
func listRun(opts *ListOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
releases, err := fetchReleases(httpClient, baseRepo, opts.LimitResults)
if err != nil {
return err
}
now := time.Now()
table := utils.NewTablePrinter(opts.IO)
iofmt := opts.IO.ColorScheme()
seenLatest := false
for _, rel := range releases {
title := text.ReplaceExcessiveWhitespace(rel.Name)
if title == "" {
title = rel.TagName
}
table.AddField(title, nil, nil)
badge := ""
var badgeColor func(string) string
if !rel.IsDraft && !rel.IsPrerelease && !seenLatest {
badge = "Latest"
badgeColor = iofmt.Green
seenLatest = true
} else if rel.IsDraft {
badge = "Draft"
badgeColor = iofmt.Red
} else if rel.IsPrerelease {
badge = "Pre-release"
badgeColor = iofmt.Yellow
}
table.AddField(badge, nil, badgeColor)
tagName := rel.TagName
if table.IsTTY() {
tagName = fmt.Sprintf("(%s)", tagName)
}
table.AddField(tagName, nil, nil)
pubDate := rel.PublishedAt
if rel.PublishedAt.IsZero() {
pubDate = rel.CreatedAt
}
publishedAt := pubDate.Format(time.RFC3339)
if table.IsTTY() {
publishedAt = utils.FuzzyAgo(now.Sub(pubDate))
}
table.AddField(publishedAt, nil, iofmt.Gray)
table.EndRow()
}
err = table.Render()
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,189 @@
package list
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewCmdList(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want ListOptions
wantErr string
}{
{
name: "no arguments",
args: "",
isTTY: true,
want: ListOptions{
LimitResults: 30,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
f := &cmdutil.Factory{
IOStreams: io,
}
var opts *ListOptions
cmd := NewCmdList(f, func(o *ListOptions) error {
opts = o
return nil
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(tt.args)
require.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.LimitResults, opts.LimitResults)
})
}
}
func Test_listRun(t *testing.T) {
frozenTime, err := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
require.NoError(t, err)
tests := []struct {
name string
isTTY bool
opts ListOptions
wantErr string
wantStdout string
wantStderr string
}{
{
name: "list releases",
isTTY: true,
opts: ListOptions{
LimitResults: 30,
},
wantStdout: heredoc.Doc(`
v1.1.0 Draft (v1.1.0) about 1 day ago
The big 1.0 Latest (v1.0.0) about 1 day ago
1.0 release candidate Pre-release (v1.0.0-pre.2) about 1 day ago
New features (v0.9.2) about 1 day ago
`),
wantStderr: ``,
},
{
name: "machine-readable",
isTTY: false,
opts: ListOptions{
LimitResults: 30,
},
wantStdout: heredoc.Doc(`
v1.1.0 Draft v1.1.0 2020-08-31T15:44:24+02:00
The big 1.0 Latest v1.0.0 2020-08-31T15:44:24+02:00
1.0 release candidate Pre-release v1.0.0-pre.2 2020-08-31T15:44:24+02:00
New features v0.9.2 2020-08-31T15:44:24+02:00
`),
wantStderr: ``,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
createdAt := frozenTime
if tt.isTTY {
createdAt = time.Now().Add(time.Duration(-24) * time.Hour)
}
fakeHTTP := &httpmock.Registry{}
fakeHTTP.Register(httpmock.GraphQL(`\bRepositoryReleaseList\(`), httpmock.StringResponse(fmt.Sprintf(`
{ "data": { "repository": { "releases": {
"nodes": [
{
"name": "",
"tagName": "v1.1.0",
"isDraft": true,
"isPrerelease": false,
"createdAt": "%[1]s",
"publishedAt": "%[1]s"
},
{
"name": "The big 1.0",
"tagName": "v1.0.0",
"isDraft": false,
"isPrerelease": false,
"createdAt": "%[1]s",
"publishedAt": "%[1]s"
},
{
"name": "1.0 release candidate",
"tagName": "v1.0.0-pre.2",
"isDraft": false,
"isPrerelease": true,
"createdAt": "%[1]s",
"publishedAt": "%[1]s"
},
{
"name": "New features",
"tagName": "v0.9.2",
"isDraft": false,
"isPrerelease": false,
"createdAt": "%[1]s",
"publishedAt": "%[1]s"
}
]
} } } }`, createdAt.Format(time.RFC3339))))
tt.opts.IO = io
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: fakeHTTP}, nil
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := listRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,33 @@
package release
import (
cmdCreate "github.com/cli/cli/pkg/cmd/release/create"
cmdDelete "github.com/cli/cli/pkg/cmd/release/delete"
cmdDownload "github.com/cli/cli/pkg/cmd/release/download"
cmdList "github.com/cli/cli/pkg/cmd/release/list"
cmdUpload "github.com/cli/cli/pkg/cmd/release/upload"
cmdView "github.com/cli/cli/pkg/cmd/release/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdRelease(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "release <command>",
Short: "Manage GitHub releases",
Annotations: map[string]string{
"IsCore": "true",
},
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil))
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil))
return cmd
}

View file

@ -0,0 +1,163 @@
package shared
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
APIURL string `json:"url"`
UploadURL string `json:"upload_url"`
HTMLURL string `json:"html_url"`
Assets []ReleaseAsset
Author struct {
Login string
}
}
type ReleaseAsset struct {
Name string
Size int64
State string
APIURL string `json:"url"`
}
// FetchRelease finds a repository release by its tagName.
func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) {
path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), tagName)
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
if canPush, err := api.CanPushToRepo(httpClient, baseRepo); err == nil && canPush {
return FindDraftRelease(httpClient, baseRepo, tagName)
} else if err != nil {
return nil, err
}
}
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release Release
err = json.Unmarshal(b, &release)
if err != nil {
return nil, err
}
return &release, nil
}
// FetchLatestRelease finds the latest published release for a repository.
func FetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*Release, error) {
path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName())
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release Release
err = json.Unmarshal(b, &release)
if err != nil {
return nil, err
}
return &release, nil
}
// FindDraftRelease returns the latest draft release that matches tagName.
func FindDraftRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) {
path := fmt.Sprintf("repos/%s/%s/releases", baseRepo.RepoOwner(), baseRepo.RepoName())
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
perPage := 100
page := 1
for {
req, err := http.NewRequest("GET", fmt.Sprintf("%s?per_page=%d&page=%d", url, perPage, page), nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var releases []Release
err = json.Unmarshal(b, &releases)
if err != nil {
return nil, err
}
for _, r := range releases {
if r.IsDraft && r.TagName == tagName {
return &r, nil
}
}
if len(releases) < perPage {
break
}
page++
}
return nil, errors.New("release not found")
}

View file

@ -0,0 +1,215 @@
package shared
import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/cli/cli/api"
)
type AssetForUpload struct {
Name string
Label string
Size int64
MIMEType string
Open func() (io.ReadCloser, error)
ExistingURL string
}
func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
for _, arg := range args {
var label string
fn := arg
if idx := strings.IndexRune(arg, '#'); idx > 0 {
fn = arg[0:idx]
label = arg[idx+1:]
}
var fi os.FileInfo
fi, err = os.Stat(fn)
if err != nil {
return
}
assets = append(assets, &AssetForUpload{
Open: func() (io.ReadCloser, error) {
return os.Open(fn)
},
Size: fi.Size(),
Name: fi.Name(),
Label: label,
MIMEType: typeForFilename(fi.Name()),
})
}
return
}
func typeForFilename(fn string) string {
ext := fileExt(fn)
switch ext {
case ".zip":
return "application/zip"
case ".js":
return "application/javascript"
case ".tgz", ".tar.gz":
return "application/x-gtar"
case ".bz2":
return "application/x-bzip2"
case ".dmg":
return "application/x-apple-diskimage"
case ".rpm":
return "application/x-rpm"
case ".deb":
return "application/x-debian-package"
}
t := mime.TypeByExtension(ext)
if t == "" {
return "application/octet-stream"
}
return t
}
func fileExt(fn string) string {
fn = strings.ToLower(fn)
if strings.HasSuffix(fn, ".tar.gz") {
return ".tar.gz"
}
return path.Ext(fn)
}
func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
jobs := make(chan AssetForUpload, len(assets))
results := make(chan error, len(assets))
if len(assets) < numWorkers {
numWorkers = len(assets)
}
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- uploadWithDelete(httpClient, uploadURL, a)
}
}()
}
for _, a := range assets {
jobs <- *a
}
close(jobs)
var uploadError error
for i := 0; i < len(assets); i++ {
if err := <-results; err != nil {
uploadError = err
}
}
return uploadError
}
const maxRetries = 3
func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error {
if a.ExistingURL != "" {
err := deleteAsset(httpClient, a.ExistingURL)
if err != nil {
return err
}
}
retries := 0
for {
var httpError api.HTTPError
_, err := uploadAsset(httpClient, uploadURL, a)
// retry upload several times upon receiving HTTP 5xx
if err == nil || !errors.As(err, &httpError) || httpError.StatusCode < 500 || retries < maxRetries {
return err
}
retries++
time.Sleep(time.Duration(retries) * time.Second)
}
}
func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) {
u, err := url.Parse(uploadURL)
if err != nil {
return nil, err
}
params := u.Query()
params.Set("name", asset.Name)
params.Set("label", asset.Label)
u.RawQuery = params.Encode()
f, err := asset.Open()
if err != nil {
return nil, err
}
defer f.Close()
req, err := http.NewRequest("POST", u.String(), f)
if err != nil {
return nil, err
}
req.ContentLength = asset.Size
req.Header.Set("Content-Type", asset.MIMEType)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newAsset ReleaseAsset
err = json.Unmarshal(b, &newAsset)
if err != nil {
return nil, err
}
return &newAsset, nil
}
func deleteAsset(httpClient *http.Client, assetURL string) error {
req, err := http.NewRequest("DELETE", assetURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
return nil
}

View file

@ -0,0 +1,69 @@
package shared
import "testing"
func Test_typeForFilename(t *testing.T) {
tests := []struct {
name string
file string
want string
}{
{
name: "tar",
file: "ball.tar",
want: "application/x-tar",
},
{
name: "tgz",
file: "ball.tgz",
want: "application/x-gtar",
},
{
name: "tar.gz",
file: "ball.tar.gz",
want: "application/x-gtar",
},
{
name: "bz2",
file: "ball.tar.bz2",
want: "application/x-bzip2",
},
{
name: "zip",
file: "archive.zip",
want: "application/zip",
},
{
name: "js",
file: "app.js",
want: "application/javascript",
},
{
name: "dmg",
file: "apple.dmg",
want: "application/x-apple-diskimage",
},
{
name: "rpm",
file: "package.rpm",
want: "application/x-rpm",
},
{
name: "deb",
file: "package.deb",
want: "application/x-debian-package",
},
{
name: "no extension",
file: "myfile",
want: "application/octet-stream",
},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
if got := typeForFilename(tt.file); got != tt.want {
t.Errorf("typeForFilename() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,123 @@
package upload
import (
"fmt"
"net/http"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type UploadOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
Assets []*shared.AssetForUpload
// maximum number of simultaneous uploads
Concurrency int
OverwriteExisting bool
}
func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Command {
opts := &UploadOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "upload <tag> <files>...",
Short: "Upload assets to a release",
Long: heredoc.Doc(`
Upload asset files to a GitHub Release.
To define a display label for an asset, append text starting with '#' after the
file name.
`),
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TagName = args[0]
var err error
opts.Assets, err = shared.AssetsFromArgs(args[1:])
if err != nil {
return err
}
opts.Concurrency = 5
if runF != nil {
return runF(opts)
}
return uploadRun(opts)
},
}
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing assets of the same name")
return cmd
}
func uploadRun(opts *UploadOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
uploadURL := release.UploadURL
if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
uploadURL = uploadURL[:idx]
}
var existingNames []string
for _, a := range opts.Assets {
for _, ea := range release.Assets {
if ea.Name == a.Name {
a.ExistingURL = ea.APIURL
existingNames = append(existingNames, ea.Name)
break
}
}
}
if len(existingNames) > 0 && !opts.OverwriteExisting {
return fmt.Errorf("asset under the same name already exists: %v", existingNames)
}
opts.IO.StartProgressIndicator()
err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if opts.IO.IsStdoutTTY() {
iofmt := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out, "Successfully uploaded %s to %s\n",
utils.Pluralize(len(opts.Assets), "asset"),
iofmt.Bold(release.TagName))
}
return nil
}

View file

@ -0,0 +1,193 @@
package view
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ViewOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
WebMode bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "view [<tag>]",
Short: "View information about a release",
Long: heredoc.Doc(`
View information about a GitHub Release.
Without an explicit tag name argument, the latest release in the project
is shown.
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.TagName = args[0]
}
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the release in the browser")
return cmd
}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
var release *shared.Release
if opts.TagName == "" {
release, err = shared.FetchLatestRelease(httpClient, baseRepo)
if err != nil {
return err
}
} else {
release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
}
if opts.WebMode {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.HTMLURL))
}
return utils.OpenInBrowser(release.HTMLURL)
}
if opts.IO.IsStdoutTTY() {
if err := renderReleaseTTY(opts.IO, release); err != nil {
return err
}
} else {
if err := renderReleasePlain(opts.IO.Out, release); err != nil {
return err
}
}
return nil
}
func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
iofmt := io.ColorScheme()
w := io.Out
fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName))
if release.IsDraft {
fmt.Fprintf(w, "%s • ", iofmt.Red("Draft"))
} else if release.IsPrerelease {
fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release"))
}
if release.IsDraft {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt)))))
} else {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt)))))
}
renderedDescription, err := utils.RenderMarkdown(release.Body)
if err != nil {
return err
}
fmt.Fprintln(w, renderedDescription)
if len(release.Assets) > 0 {
fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets"))
table := utils.NewTablePrinter(io)
for _, a := range release.Assets {
table.AddField(a.Name, nil, nil)
table.AddField(humanFileSize(a.Size), nil, nil)
table.EndRow()
}
err := table.Render()
if err != nil {
return err
}
fmt.Fprint(w, "\n")
}
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL)))
return nil
}
func renderReleasePlain(w io.Writer, release *shared.Release) error {
fmt.Fprintf(w, "title:\t%s\n", release.Name)
fmt.Fprintf(w, "tag:\t%s\n", release.TagName)
fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft)
fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease)
fmt.Fprintf(w, "author:\t%s\n", release.Author.Login)
fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339))
if !release.IsDraft {
fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339))
}
fmt.Fprintf(w, "url:\t%s\n", release.HTMLURL)
for _, a := range release.Assets {
fmt.Fprintf(w, "asset:\t%s\n", a.Name)
}
fmt.Fprint(w, "--\n")
fmt.Fprint(w, release.Body)
return nil
}
func humanFileSize(s int64) string {
if s < 1024 {
return fmt.Sprintf("%d B", s)
}
kb := float64(s) / 1024
if kb < 1024 {
return fmt.Sprintf("%s KiB", floatToString(kb, 2))
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%s MiB", floatToString(mb, 2))
}
gb := mb / 1024
return fmt.Sprintf("%s GiB", floatToString(gb, 2))
}
// render float to fixed precision using truncation instead of rounding
func floatToString(f float64, p uint8) string {
fs := fmt.Sprintf("%#f%0*s", f, p, "")
idx := strings.IndexRune(fs, '.')
return fs[:idx+int(p)+1]
}

View file

@ -0,0 +1,289 @@
package view
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_NewCmdView(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want ViewOptions
wantErr string
}{
{
name: "version argument",
args: "v1.2.3",
isTTY: true,
want: ViewOptions{
TagName: "v1.2.3",
WebMode: false,
},
},
{
name: "no arguments",
args: "",
isTTY: true,
want: ViewOptions{
TagName: "",
WebMode: false,
},
},
{
name: "web mode",
args: "-w",
isTTY: true,
want: ViewOptions{
TagName: "",
WebMode: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
f := &cmdutil.Factory{
IOStreams: io,
}
var opts *ViewOptions
cmd := NewCmdView(f, func(o *ViewOptions) error {
opts = o
return nil
})
cmd.PersistentFlags().StringP("repo", "R", "", "")
argv, err := shlex.Split(tt.args)
require.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want.TagName, opts.TagName)
assert.Equal(t, tt.want.WebMode, opts.WebMode)
})
}
}
func Test_viewRun(t *testing.T) {
oneHourAgo := time.Now().Add(time.Duration(-24) * time.Hour)
frozenTime, err := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
require.NoError(t, err)
tests := []struct {
name string
isTTY bool
releasedAt time.Time
opts ViewOptions
wantErr string
wantStdout string
wantStderr string
}{
{
name: "view specific release",
isTTY: true,
releasedAt: oneHourAgo,
opts: ViewOptions{
TagName: "v1.2.3",
},
wantStdout: heredoc.Doc(`
v1.2.3
MonaLisa released this about 1 day ago
Fixed bugs
Assets
windows.zip 12 B
linux.tgz 34 B
View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3
`),
wantStderr: ``,
},
{
name: "view latest release",
isTTY: true,
releasedAt: oneHourAgo,
opts: ViewOptions{
TagName: "",
},
wantStdout: heredoc.Doc(`
v1.2.3
MonaLisa released this about 1 day ago
Fixed bugs
Assets
windows.zip 12 B
linux.tgz 34 B
View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3
`),
wantStderr: ``,
},
{
name: "view machine-readable",
isTTY: false,
releasedAt: frozenTime,
opts: ViewOptions{
TagName: "v1.2.3",
},
wantStdout: heredoc.Doc(`
title:
tag: v1.2.3
draft: false
prerelease: false
author: MonaLisa
created: 2020-08-31T15:44:24+02:00
published: 2020-08-31T15:44:24+02:00
url: https://github.com/OWNER/REPO/releases/tags/v1.2.3
asset: windows.zip
asset: linux.tgz
--
* Fixed bugs
`),
wantStderr: ``,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
path := "repos/OWNER/REPO/releases/tags/v1.2.3"
if tt.opts.TagName == "" {
path = "repos/OWNER/REPO/releases/latest"
}
fakeHTTP := &httpmock.Registry{}
fakeHTTP.Register(httpmock.REST("GET", path), httpmock.StringResponse(fmt.Sprintf(`{
"tag_name": "v1.2.3",
"draft": false,
"author": { "login": "MonaLisa" },
"body": "* Fixed bugs\n",
"created_at": "%[1]s",
"published_at": "%[1]s",
"html_url": "https://github.com/OWNER/REPO/releases/tags/v1.2.3",
"assets": [
{ "name": "windows.zip", "size": 12 },
{ "name": "linux.tgz", "size": 34 }
]
}`, tt.releasedAt.Format(time.RFC3339))))
tt.opts.IO = io
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: fakeHTTP}, nil
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := viewRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}
func Test_humanFileSize(t *testing.T) {
tests := []struct {
name string
size int64
want string
}{
{
name: "min bytes",
size: 1,
want: "1 B",
},
{
name: "max bytes",
size: 1023,
want: "1023 B",
},
{
name: "min kibibytes",
size: 1024,
want: "1.00 KiB",
},
{
name: "max kibibytes",
size: 1024*1024 - 1,
want: "1023.99 KiB",
},
{
name: "min mibibytes",
size: 1024 * 1024,
want: "1.00 MiB",
},
{
name: "fractional mibibytes",
size: 1024*1024*12 + 1024*350,
want: "12.34 MiB",
},
{
name: "max mibibytes",
size: 1024*1024*1024 - 1,
want: "1023.99 MiB",
},
{
name: "min gibibytes",
size: 1024 * 1024 * 1024,
want: "1.00 GiB",
},
{
name: "fractional gibibytes",
size: 1024 * 1024 * 1024 * 1.5,
want: "1.50 GiB",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := humanFileSize(tt.size); got != tt.want {
t.Errorf("humanFileSize() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -73,6 +73,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.Name = args[0]
}
if !opts.IO.CanPrompt() {
if opts.Name == "" {
return &cmdutil.FlagError{Err: errors.New("name argument required when not running interactively")}
}
if !opts.Internal && !opts.Private && !opts.Public {
return &cmdutil.FlagError{Err: errors.New("--public, --private, or --internal required when not running interactively")}
}
}
if runF != nil {
return runF(opts)
}
@ -264,7 +274,7 @@ func createRun(opts *CreateOptions) error {
if isTTY {
fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, remoteURL)
}
} else if isTTY {
} else if opts.IO.CanPrompt() {
doSetup := createLocalDirectory
if !doSetup {
err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup)

View file

@ -22,6 +22,8 @@ import (
func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
fac := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
@ -109,8 +111,8 @@ func TestRepoCreate(t *testing.T) {
t.Errorf("error running command `repo create`: %v", err)
}
assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
@ -191,8 +193,8 @@ func TestRepoCreate_org(t *testing.T) {
t.Errorf("error running command `repo create`: %v", err)
}
assert.Equal(t, "https://github.com/ORG/REPO\n", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created repository ORG/REPO on GitHub\n✓ Added remote https://github.com/ORG/REPO.git\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
@ -273,8 +275,8 @@ func TestRepoCreate_orgWithTeam(t *testing.T) {
t.Errorf("error running command `repo create`: %v", err)
}
assert.Equal(t, "https://github.com/ORG/REPO\n", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created repository ORG/REPO on GitHub\n✓ Added remote https://github.com/ORG/REPO.git\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
@ -363,8 +365,8 @@ func TestRepoCreate_template(t *testing.T) {
t.Errorf("error running command `repo create`: %v", err)
}
assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")

View file

@ -55,7 +55,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`,
RunE: func(cmd *cobra.Command, args []string) error {
promptOk := opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY()
promptOk := opts.IO.CanPrompt()
if len(args) > 0 {
opts.Repository = args[0]
}

View file

@ -2,9 +2,9 @@ package view
import (
"fmt"
"html/template"
"net/http"
"strings"
"text/template"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"

View file

@ -515,3 +515,86 @@ func Test_ViewRun_WithoutUsername(t *testing.T) {
assert.Equal(t, "", stderr.String())
reg.Verify(t)
}
func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
repoName string
stdoutTTY bool
wantOut string
wantStderr string
wantErr bool
}{
{
name: "nontty",
wantOut: heredoc.Doc(`
name: OWNER/REPO
description: Some basic special characters " & / < > '
--
# < is always > than & ' and "
`),
},
{
name: "no args",
stdoutTTY: true,
wantOut: heredoc.Doc(`
OWNER/REPO
Some basic special characters " & / < > '
# < is always > than & ' and "
View this repository on GitHub: https://github.com/OWNER/REPO
`),
},
}
for _, tt := range tests {
if tt.opts == nil {
tt.opts = &ViewOptions{}
}
if tt.repoName == "" {
tt.repoName = "OWNER/REPO"
}
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
repo, _ := ghrepo.FromFullName(tt.repoName)
return repo, nil
}
reg := &httpmock.Registry{}
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": {
"repository": {
"description": "Some basic special characters \" & / < > '"
} } }`))
reg.Register(
httpmock.REST("GET", fmt.Sprintf("repos/%s/readme", tt.repoName)),
httpmock.StringResponse(`
{ "name": "readme.md",
"content": "IyA8IGlzIGFsd2F5cyA+IHRoYW4gJiAnIGFuZCAi"}`))
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, stderr := iostreams.Test()
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
io.SetStdoutTTY(tt.stdoutTTY)
if err := viewRun(tt.opts); (err != nil) != tt.wantErr {
t.Errorf("viewRun() error = %v, wantErr %v", err, tt.wantErr)
}
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}

View file

@ -3,9 +3,9 @@ package root
import (
"bytes"
"fmt"
"regexp"
"strings"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@ -28,7 +28,7 @@ func rootUsageFunc(command *cobra.Command) error {
flagUsages := command.LocalFlags().FlagUsages()
if flagUsages != "" {
command.Println("\n\nFlags:")
command.Print(indent(dedent(flagUsages), " "))
command.Print(text.Indent(dedent(flagUsages), " "))
}
return nil
}
@ -150,7 +150,7 @@ Read the manual at https://cli.github.com/manual`})
if e.Title != "" {
// If there is a title, add indentation to each line in the body
fmt.Fprintln(out, utils.Bold(e.Title))
fmt.Fprintln(out, indent(strings.Trim(e.Body, "\r\n"), " "))
fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " "))
} else {
// If there is no title print the body as is
fmt.Fprintln(out, e.Body)
@ -165,15 +165,6 @@ func rpad(s string, padding int) string {
return fmt.Sprintf(template, s)
}
var lineRE = regexp.MustCompile(`(?m)^`)
func indent(s, indent string) string {
if len(strings.TrimSpace(s)) == 0 {
return s
}
return lineRE.ReplaceAllLiteralString(s, indent)
}
func dedent(s string) string {
lines := strings.Split(s, "\n")
minIndent := -1

View file

@ -44,47 +44,3 @@ func TestDedent(t *testing.T) {
}
}
}
func Test_indent(t *testing.T) {
type args struct {
s string
indent string
}
tests := []struct {
name string
args args
want string
}{
{
name: "empty",
args: args{
s: "",
indent: "--",
},
want: "",
},
{
name: "blank",
args: args{
s: "\n",
indent: "--",
},
want: "\n",
},
{
name: "indent",
args: args{
s: "one\ntwo\nthree",
indent: "--",
},
want: "--one\n--two\n--three",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := indent(tt.args.s, tt.args.indent); got != tt.want {
t.Errorf("indent() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -18,6 +18,7 @@ import (
gistCmd "github.com/cli/cli/pkg/cmd/gist"
issueCmd "github.com/cli/cli/pkg/cmd/issue"
prCmd "github.com/cli/cli/pkg/cmd/pr"
releaseCmd "github.com/cli/cli/pkg/cmd/release"
repoCmd "github.com/cli/cli/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
"github.com/cli/cli/pkg/cmdutil"
@ -130,6 +131,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory))
cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory))
return cmd

86
pkg/iostreams/color.go Normal file
View file

@ -0,0 +1,86 @@
package iostreams
import "github.com/mgutz/ansi"
var (
magenta = ansi.ColorFunc("magenta")
cyan = ansi.ColorFunc("cyan")
red = ansi.ColorFunc("red")
yellow = ansi.ColorFunc("yellow")
blue = ansi.ColorFunc("blue")
green = ansi.ColorFunc("green")
gray = ansi.ColorFunc("black+h")
bold = ansi.ColorFunc("default+b")
)
func NewColorScheme(enabled bool) *ColorScheme {
return &ColorScheme{enabled: enabled}
}
type ColorScheme struct {
enabled bool
}
func (c *ColorScheme) Bold(t string) string {
if !c.enabled {
return t
}
return bold(t)
}
func (c *ColorScheme) Red(t string) string {
if !c.enabled {
return t
}
return red(t)
}
func (c *ColorScheme) Yellow(t string) string {
if !c.enabled {
return t
}
return yellow(t)
}
func (c *ColorScheme) Green(t string) string {
if !c.enabled {
return t
}
return green(t)
}
func (c *ColorScheme) Gray(t string) string {
if !c.enabled {
return t
}
return gray(t)
}
func (c *ColorScheme) Magenta(t string) string {
if !c.enabled {
return t
}
return magenta(t)
}
func (c *ColorScheme) Cyan(t string) string {
if !c.enabled {
return t
}
return cyan(t)
}
func (c *ColorScheme) Blue(t string) string {
if !c.enabled {
return t
}
return blue(t)
}
func (c *ColorScheme) SuccessIcon() string {
return c.Green("✓")
}
func (c *ColorScheme) WarningIcon() string {
return c.Yellow("!")
}

View file

@ -9,7 +9,9 @@ import (
"os/exec"
"strconv"
"strings"
"time"
"github.com/briandowns/spinner"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"golang.org/x/crypto/ssh/terminal"
@ -24,12 +26,17 @@ type IOStreams struct {
originalOut io.Writer
colorEnabled bool
progressIndicatorEnabled bool
progressIndicator *spinner.Spinner
stdinTTYOverride bool
stdinIsTTY bool
stdoutTTYOverride bool
stdoutIsTTY bool
stderrTTYOverride bool
stderrIsTTY bool
neverPrompt bool
}
func (s *IOStreams) ColorEnabled() bool {
@ -81,6 +88,35 @@ func (s *IOStreams) IsStderrTTY() bool {
return false
}
func (s *IOStreams) CanPrompt() bool {
if s.neverPrompt {
return false
}
return s.IsStdinTTY() && s.IsStdoutTTY()
}
func (s *IOStreams) SetNeverPrompt(v bool) {
s.neverPrompt = v
}
func (s *IOStreams) StartProgressIndicator() {
if !s.progressIndicatorEnabled {
return
}
sp := spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(s.ErrOut))
sp.Start()
s.progressIndicator = sp
}
func (s *IOStreams) StopProgressIndicator() {
if s.progressIndicator == nil {
return
}
s.progressIndicator.Stop()
s.progressIndicator = nil
}
func (s *IOStreams) TerminalWidth() int {
defaultWidth := 80
out := s.Out
@ -105,6 +141,10 @@ func (s *IOStreams) TerminalWidth() int {
return defaultWidth
}
func (s *IOStreams) ColorScheme() *ColorScheme {
return NewColorScheme(s.ColorEnabled())
}
func System() *IOStreams {
stdoutIsTTY := isTerminal(os.Stdout)
stderrIsTTY := isTerminal(os.Stderr)
@ -117,6 +157,10 @@ func System() *IOStreams {
colorEnabled: os.Getenv("NO_COLOR") == "" && stdoutIsTTY,
}
if stdoutIsTTY && stderrIsTTY {
io.progressIndicatorEnabled = true
}
// prevent duplicate isTerminal queries now that we know the answer
io.SetStdoutTTY(stdoutIsTTY)
io.SetStderrTTY(stderrIsTTY)

View file

@ -5,16 +5,12 @@ package surveyext
// To see what we extended, search through for EXTENDED comments.
import (
"bytes"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
shellquote "github.com/kballard/go-shellquote"
)
var (
@ -139,63 +135,11 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
continue
}
// prepare the temp file
pattern := e.FileName
if pattern == "" {
pattern = "survey*.txt"
}
f, err := ioutil.TempFile("", pattern)
if err != nil {
return "", err
}
defer os.Remove(f.Name())
// write utf8 BOM header
// The reason why we do this is because notepad.exe on Windows determines the
// encoding of an "empty" text file by the locale, for example, GBK in China,
// while golang string only handles utf8 well. However, a text file with utf8
// BOM header is not considered "empty" on Windows, and the encoding will then
// be determined utf8 by notepad.exe, instead of GBK or other encodings.
if _, err := f.Write(bom); err != nil {
return "", err
}
// write initial value
if _, err := f.WriteString(initialValue); err != nil {
return "", err
}
// close the fd to prevent the editor unable to save file
if err := f.Close(); err != nil {
return "", err
}
stdio := e.Stdio()
args, err := shellquote.Split(e.editorCommand())
text, err := Edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor)
if err != nil {
return "", err
}
args = append(args, f.Name())
// open the editor
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = stdio.In
cmd.Stdout = stdio.Out
cmd.Stderr = stdio.Err
cursor.Show()
if err := cmd.Run(); err != nil {
return "", err
}
// raw is a BOM-unstripped UTF8 byte slice
raw, err := ioutil.ReadFile(f.Name())
if err != nil {
return "", err
}
// strip BOM header
text := string(bytes.TrimPrefix(raw, bom))
// check length, return default value on empty
if len(text) == 0 && !e.AppendDefault {

View file

@ -0,0 +1,80 @@
package surveyext
import (
"bytes"
"io"
"io/ioutil"
"os"
"os/exec"
shellquote "github.com/kballard/go-shellquote"
)
type showable interface {
Show()
}
func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable) (string, error) {
// prepare the temp file
pattern := fn
if pattern == "" {
pattern = "survey*.txt"
}
f, err := ioutil.TempFile("", pattern)
if err != nil {
return "", err
}
defer os.Remove(f.Name())
// write utf8 BOM header
// The reason why we do this is because notepad.exe on Windows determines the
// encoding of an "empty" text file by the locale, for example, GBK in China,
// while golang string only handles utf8 well. However, a text file with utf8
// BOM header is not considered "empty" on Windows, and the encoding will then
// be determined utf8 by notepad.exe, instead of GBK or other encodings.
if _, err := f.Write(bom); err != nil {
return "", err
}
// write initial value
if _, err := f.WriteString(initialValue); err != nil {
return "", err
}
// close the fd to prevent the editor unable to save file
if err := f.Close(); err != nil {
return "", err
}
if editorCommand == "" {
editorCommand = defaultEditor
}
args, err := shellquote.Split(editorCommand)
if err != nil {
return "", err
}
args = append(args, f.Name())
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
if cursor != nil {
cursor.Show()
}
// open the editor
if err := cmd.Run(); err != nil {
return "", err
}
// raw is a BOM-unstripped UTF8 byte slice
raw, err := ioutil.ReadFile(f.Name())
if err != nil {
return "", err
}
// strip BOM header
return string(bytes.TrimPrefix(raw, bom)), nil
}

15
pkg/text/indent.go Normal file
View file

@ -0,0 +1,15 @@
package text
import (
"regexp"
"strings"
)
var lineRE = regexp.MustCompile(`(?m)^`)
func Indent(s, indent string) string {
if len(strings.TrimSpace(s)) == 0 {
return s
}
return lineRE.ReplaceAllLiteralString(s, indent)
}

47
pkg/text/indent_test.go Normal file
View file

@ -0,0 +1,47 @@
package text
import "testing"
func Test_Indent(t *testing.T) {
type args struct {
s string
indent string
}
tests := []struct {
name string
args args
want string
}{
{
name: "empty",
args: args{
s: "",
indent: "--",
},
want: "",
},
{
name: "blank",
args: args{
s: "\n",
indent: "--",
},
want: "\n",
},
{
name: "indent",
args: args{
s: "one\ntwo\nthree",
indent: "--",
},
want: "--one\n--two\n--three",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Indent(tt.args.s, tt.args.indent); got != tt.want {
t.Errorf("indent() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -1,80 +1,75 @@
package text
import (
"golang.org/x/text/width"
runewidth "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
)
// DisplayWidth calculates what the rendered width of a string may be
func DisplayWidth(s string) int {
w := 0
for _, r := range s {
w += runeDisplayWidth(r)
}
return w
}
const (
ellipsisWidth = 3
minWidthForEllipsis = 5
)
// DisplayWidth calculates what the rendered width of a string may be
func DisplayWidth(s string) int {
g := uniseg.NewGraphemes(s)
w := 0
for g.Next() {
w += graphemeWidth(g)
}
return w
}
// Truncate shortens a string to fit the maximum display width
func Truncate(max int, s string) string {
func Truncate(maxWidth int, s string) string {
w := DisplayWidth(s)
if w <= max {
if w <= maxWidth {
return s
}
useEllipsis := false
if max >= minWidthForEllipsis {
if maxWidth >= minWidthForEllipsis {
useEllipsis = true
max -= ellipsisWidth
maxWidth -= ellipsisWidth
}
cw := 0
ri := 0
for _, r := range s {
rw := runeDisplayWidth(r)
if cw+rw > max {
g := uniseg.NewGraphemes(s)
r := ""
rWidth := 0
for {
g.Next()
gWidth := graphemeWidth(g)
if rWidth+gWidth <= maxWidth {
r += g.Str()
rWidth += gWidth
continue
} else {
break
}
cw += rw
ri++
}
res := string([]rune(s)[:ri])
if useEllipsis {
res += "..."
}
if cw < max {
// compensate if truncating a wide character left an odd space
res += " "
}
return res
}
var runeDisplayWidthOverrides = map[rune]int{
'“': 1,
'”': 1,
'': 1,
'': 1,
'': 1, // en dash
'—': 1, // em dash
'→': 1,
'…': 1,
'•': 1, // bullet
'·': 1, // middle dot
}
func runeDisplayWidth(r rune) int {
if w, ok := runeDisplayWidthOverrides[r]; ok {
return w
r += "..."
}
switch width.LookupRune(r).Kind() {
case width.EastAsianWide, width.EastAsianAmbiguous, width.EastAsianFullwidth:
return 2
default:
return 1
if rWidth < maxWidth {
r += " "
}
return r
}
// graphemeWidth calculates what the rendered width of a grapheme may be
func graphemeWidth(g *uniseg.Graphemes) int {
// If grapheme spans one rune use that rune width
// If grapheme spans multiple runes use the first non-zero rune width
w := 0
for _, r := range g.Runes() {
w = runewidth.RuneWidth(r)
if w > 0 {
break
}
}
return w
}

View file

@ -62,6 +62,22 @@ func TestTruncate(t *testing.T) {
},
want: "a프로젝... ",
},
{
name: "Emoji",
args: args{
max: 11,
s: "💡💡💡💡💡💡💡💡💡💡💡💡",
},
want: "💡💡💡💡...",
},
{
name: "Accented characters",
args: args{
max: 11,
s: "é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́",
},
want: "é́́é́́é́́é́́é́́é́́é́́é́́...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -128,6 +144,11 @@ func TestDisplayWidth(t *testing.T) {
text: `👍`,
want: 2,
},
{
name: "accent character",
text: `é́́`,
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -13,4 +13,40 @@ Architectures: i386 amd64 arm64
Components: main
Description: The GitHub CLI - ubuntu focal repo
SignWith: C99B11DEB97541F0
DebOverride: override.focal
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: precise
Architectures: i386 amd64 arm64
Components: main
Description: The GitHub CLI - ubuntu precise repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: bionic
Architectures: i386 amd64 arm64
Components: main
Description: The GitHub CLI - ubuntu bionic repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: trusty
Architectures: i386 amd64 arm64
Components: main
Description: The GitHub CLI - ubuntu trusty repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: xenial
Architectures: i386 amd64 arm64
Components: main
Description: The GitHub CLI - ubuntu xenial repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu

View file

@ -118,3 +118,11 @@ func DisplayURL(urlStr string) string {
func GreenCheck() string {
return Green("✓")
}
func YellowDash() string {
return Yellow("-")
}
func RedX() string {
return Red("X")
}