Merge branch 'trunk' into readme-updates
This commit is contained in:
commit
f43a65a69f
82 changed files with 4827 additions and 374 deletions
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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][].
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
24
go.mod
|
|
@ -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
37
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package config
|
||||
package authflow
|
||||
|
||||
const oauthSuccessPage = `
|
||||
<!doctype html>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: Bug Report
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
I wanna report a bug
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
name: Submit a request
|
||||
about: Propose an improvement
|
||||
title: Enhancement Proposal
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
I have a suggestion for an enhancement
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
227
pkg/cmd/pr/checks/checks.go
Normal 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
|
||||
}
|
||||
211
pkg/cmd/pr/checks/checks_test.go
Normal file
211
pkg/cmd/pr/checks/checks_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
41
pkg/cmd/pr/checks/fixtures/allPassing.json
Normal file
41
pkg/cmd/pr/checks/fixtures/allPassing.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
41
pkg/cmd/pr/checks/fixtures/someFailing.json
Normal file
41
pkg/cmd/pr/checks/fixtures/someFailing.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
41
pkg/cmd/pr/checks/fixtures/somePending.json
Normal file
41
pkg/cmd/pr/checks/fixtures/somePending.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
38
pkg/cmd/pr/checks/fixtures/withStatuses.json
Normal file
38
pkg/cmd/pr/checks/fixtures/withStatuses.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: "Bug fix"
|
||||
about: Fix a bug
|
||||
|
||||
---
|
||||
|
||||
Fixes a bug and Closes an issue
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
365
pkg/cmd/release/create/create.go
Normal file
365
pkg/cmd/release/create/create.go
Normal 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(¬esFile, "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")
|
||||
}
|
||||
383
pkg/cmd/release/create/create_test.go
Normal file
383
pkg/cmd/release/create/create_test.go
Normal 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, ¶ms)
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
78
pkg/cmd/release/create/http.go
Normal file
78
pkg/cmd/release/create/http.go
Normal 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
|
||||
}
|
||||
119
pkg/cmd/release/delete/delete.go
Normal file
119
pkg/cmd/release/delete/delete.go
Normal 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
|
||||
}
|
||||
160
pkg/cmd/release/delete/delete_test.go
Normal file
160
pkg/cmd/release/delete/delete_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
208
pkg/cmd/release/download/download.go
Normal file
208
pkg/cmd/release/download/download.go
Normal 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
|
||||
}
|
||||
254
pkg/cmd/release/download/download_test.go
Normal file
254
pkg/cmd/release/download/download_test.go
Normal 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
|
||||
}
|
||||
72
pkg/cmd/release/list/http.go
Normal file
72
pkg/cmd/release/list/http.go
Normal 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
|
||||
}
|
||||
115
pkg/cmd/release/list/list.go
Normal file
115
pkg/cmd/release/list/list.go
Normal 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
|
||||
}
|
||||
189
pkg/cmd/release/list/list_test.go
Normal file
189
pkg/cmd/release/list/list_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
33
pkg/cmd/release/release.go
Normal file
33
pkg/cmd/release/release.go
Normal 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
|
||||
}
|
||||
163
pkg/cmd/release/shared/fetch.go
Normal file
163
pkg/cmd/release/shared/fetch.go
Normal 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")
|
||||
}
|
||||
215
pkg/cmd/release/shared/upload.go
Normal file
215
pkg/cmd/release/shared/upload.go
Normal 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
|
||||
}
|
||||
69
pkg/cmd/release/shared/upload_test.go
Normal file
69
pkg/cmd/release/shared/upload_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
123
pkg/cmd/release/upload/upload.go
Normal file
123
pkg/cmd/release/upload/upload.go
Normal 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
|
||||
}
|
||||
193
pkg/cmd/release/view/view.go
Normal file
193
pkg/cmd/release/view/view.go
Normal 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]
|
||||
}
|
||||
289
pkg/cmd/release/view/view_test.go
Normal file
289
pkg/cmd/release/view/view_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package view
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
86
pkg/iostreams/color.go
Normal 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("!")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
80
pkg/surveyext/editor_manual.go
Normal file
80
pkg/surveyext/editor_manual.go
Normal 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
15
pkg/text/indent.go
Normal 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
47
pkg/text/indent_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue