From c4f5d6db584cd33d0cfbfe2d3837f403ee18d3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 19 Aug 2020 18:25:02 +0200 Subject: [PATCH 01/70] Preliminary `gh release` commands --- api/client.go | 63 +++++++++++++++-- api/queries_pr.go | 2 +- pkg/cmd/release/create/create.go | 114 +++++++++++++++++++++++++++++++ pkg/cmd/release/list/http.go | 71 +++++++++++++++++++ pkg/cmd/release/list/list.go | 79 +++++++++++++++++++++ pkg/cmd/release/release.go | 27 ++++++++ pkg/cmd/release/view/http.go | 51 ++++++++++++++ pkg/cmd/release/view/view.go | 75 ++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 9 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 pkg/cmd/release/create/create.go create mode 100644 pkg/cmd/release/list/http.go create mode 100644 pkg/cmd/release/list/list.go create mode 100644 pkg/cmd/release/release.go create mode 100644 pkg/cmd/release/view/http.go create mode 100644 pkg/cmd/release/view/view.go diff --git a/api/client.go b/api/client.go index 2556f6317..e30eb18b1 100644 --- a/api/client.go +++ b/api/client.go @@ -191,6 +191,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]) + } 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 +225,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 +301,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 +325,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 +345,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 +365,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 { diff --git a/api/queries_pr.go b/api/queries_pr.go index f2b398adf..13c489986 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -227,7 +227,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 diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go new file mode 100644 index 000000000..0e492dcc6 --- /dev/null +++ b/pkg/cmd/release/create/create.go @@ -0,0 +1,114 @@ +package list + +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/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new 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 createRun(opts) + }, + } + + 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 + } + + params := map[string]interface{}{ + "tag_name": opts.TagName, + } + + bodyBytes, err := json.Marshal(params) + if err != nil { + return err + } + + path := fmt.Sprintf("repos/%s/%s/releases", baseRepo.RepoOwner(), baseRepo.RepoName()) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + 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) + } + + if resp.StatusCode == http.StatusNoContent { + return nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + var newRelease struct { + HTMLURL string `json:"html_url"` + AssetsURL string `json:"assets_url"` + } + + err = json.Unmarshal(b, &newRelease) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL) + + return nil +} diff --git a/pkg/cmd/release/list/http.go b/pkg/cmd/release/list/http.go new file mode 100644 index 000000000..778256ed7 --- /dev/null +++ b/pkg/cmd/release/list/http.go @@ -0,0 +1,71 @@ +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 + 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 +} diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go new file mode 100644 index 000000000..61a9c097a --- /dev/null +++ b/pkg/cmd/release/list/list.go @@ -0,0 +1,79 @@ +package list + +import ( + "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) + for _, rel := range releases { + table.AddField(rel.TagName, nil, nil) + table.AddField(text.ReplaceExcessiveWhitespace(rel.Name), nil, nil) + table.AddField(utils.FuzzyAgo(now.Sub(rel.PublishedAt)), nil, nil) + table.EndRow() + } + err = table.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go new file mode 100644 index 000000000..de2613acd --- /dev/null +++ b/pkg/cmd/release/release.go @@ -0,0 +1,27 @@ +package release + +import ( + cmdCreate "github.com/cli/cli/pkg/cmd/release/create" + cmdList "github.com/cli/cli/pkg/cmd/release/list" + 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 ", + Short: "Manage GitHub releases", + Annotations: map[string]string{ + "IsCore": "true", + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + return cmd +} diff --git a/pkg/cmd/release/view/http.go b/pkg/cmd/release/view/http.go new file mode 100644 index 000000000..7422bf40c --- /dev/null +++ b/pkg/cmd/release/view/http.go @@ -0,0 +1,51 @@ +package view + +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 { + TagName string + Name string + Description string + URL string + IsDraft bool + IsPrerelease bool + PublishedAt time.Time + + Author struct { + Login string + } + + ReleaseAssets struct { + Nodes []struct { + Name string + Size int + } + } `graphql:"releaseAssets(first: 100)"` +} + +func fetchRelease(httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { + var query struct { + Repository struct { + Release *Release `graphql:"release(tagName: $tagName)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "tagName": githubv4.String(tagName), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient) + err := gql.QueryNamed(context.Background(), "RepositoryReleaseByTag", &query, variables) + return query.Repository.Release, err +} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go new file mode 100644 index 000000000..054d0021d --- /dev/null +++ b/pkg/cmd/release/view/view.go @@ -0,0 +1,75 @@ +package view + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/internal/ghrepo" + "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 +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "view ", + Short: "View information about 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 viewRun(opts) + }, + } + + 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 + } + + release, err := fetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.Out, "%s\n", release.TagName) + + renderedDescription, err := utils.RenderMarkdown(release.Description) + if err != nil { + return err + } + fmt.Fprintln(opts.IO.Out, renderedDescription) + + fmt.Fprintf(opts.IO.Out, "%s\n", release.URL) + + return nil +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 39c95469d..4c6b68d3d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -16,6 +16,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" @@ -114,6 +115,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 From 4e05db97e45b539c2c20de755461d13df69e6391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 20 Aug 2020 17:59:47 +0200 Subject: [PATCH 02/70] Add `release upload` command --- pkg/cmd/release/create/create.go | 4 - pkg/cmd/release/release.go | 2 + pkg/cmd/release/upload/http.go | 121 ++++++++++++++++++++ pkg/cmd/release/upload/upload.go | 186 +++++++++++++++++++++++++++++++ pkg/iostreams/iostreams.go | 38 ++++++- 5 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/release/upload/http.go create mode 100644 pkg/cmd/release/upload/upload.go diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 0e492dcc6..afb30e829 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -89,10 +89,6 @@ func createRun(opts *CreateOptions) error { return api.HandleHTTPError(resp) } - if resp.StatusCode == http.StatusNoContent { - return nil - } - b, err := ioutil.ReadAll(resp.Body) if err != nil { return err diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index de2613acd..dedf38afe 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -3,6 +3,7 @@ package release import ( cmdCreate "github.com/cli/cli/pkg/cmd/release/create" 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" @@ -22,6 +23,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil)) return cmd } diff --git a/pkg/cmd/release/upload/http.go b/pkg/cmd/release/upload/http.go new file mode 100644 index 000000000..8b66c690d --- /dev/null +++ b/pkg/cmd/release/upload/http.go @@ -0,0 +1,121 @@ +package upload + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" +) + +type Release struct { + UploadURL string `json:"upload_url"` + Assets []ReleaseAsset +} + +type ReleaseAsset struct { + Name string + State string + URL string +} + +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 + } + + 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 release Release + err = json.Unmarshal(b, &release) + if err != nil { + return nil, err + } + + return &release, nil +} + +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() + + req, err := http.NewRequest("POST", u.String(), asset.Data) + 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 +} diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go new file mode 100644 index 000000000..beefb55d5 --- /dev/null +++ b/pkg/cmd/release/upload/upload.go @@ -0,0 +1,186 @@ +package upload + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type UploadOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + Assets []*AssetForUpload + + // maximum number of simultaneous uploads + Concurrency int + OverwriteExisting bool +} + +type AssetForUpload struct { + Name string + Label string + + Data io.ReadCloser + Size int64 + MIMEType string + + ExistingURL string +} + +func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Command { + opts := &UploadOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "upload ...", + Short: "Upload assets to a release", + 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 = 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 assetsFromArgs(args []string) (assets []*AssetForUpload, err error) { + for _, fn := range args { + var f *os.File + var fi os.FileInfo + + f, err = os.Open(fn) + if err != nil { + return + } + fi, err = f.Stat() + if err != nil { + return + } + + assets = append(assets, &AssetForUpload{ + Data: f, + Size: fi.Size(), + Name: filepath.Base(fn), + Label: "", + // TODO: infer content type from file extension + MIMEType: "application/octet-stream", + }) + } + return +} + +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 := 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.URL + 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 = concurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.EndProgressIndicator() + if err != nil { + return err + } + + return nil +} + +func concurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error { + jobs := make(chan AssetForUpload, len(assets)) + results := make(chan error, 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 +} + +func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error { + if a.ExistingURL != "" { + err := deleteAsset(httpClient, a.ExistingURL) + if err != nil { + return err + } + } + + defer a.Data.Close() + _, err := uploadAsset(httpClient, uploadURL, a) + return err +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index cf69ecd8c..25d447371 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -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" @@ -22,6 +24,9 @@ type IOStreams struct { colorEnabled bool + progressIndicatorEnabled bool + progressIndicator *spinner.Spinner + stdinTTYOverride bool stdinIsTTY bool stdoutTTYOverride bool @@ -79,6 +84,23 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +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) EndProgressIndicator() { + if s.progressIndicator == nil { + return + } + s.progressIndicator.Stop() + s.progressIndicator = nil +} + func (s *IOStreams) TerminalWidth() int { defaultWidth := 80 if s.stdoutTTYOverride { @@ -104,18 +126,30 @@ func (s *IOStreams) TerminalWidth() int { func System() *IOStreams { var out io.Writer = os.Stdout + stdoutIsTTY := isTerminal(os.Stdout) + stderrIsTTY := isTerminal(os.Stderr) + var colorEnabled bool - if os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout) { + if os.Getenv("NO_COLOR") == "" && stdoutIsTTY { out = colorable.NewColorable(os.Stdout) colorEnabled = true } - return &IOStreams{ + io := &IOStreams{ In: os.Stdin, Out: out, ErrOut: os.Stderr, colorEnabled: colorEnabled, } + + if stdoutIsTTY && stderrIsTTY { + io.progressIndicatorEnabled = true + } + + // prevent duplicate isTerminal queries now that we know the answer + io.SetStdoutTTY(stdoutIsTTY) + io.SetStderrTTY(stderrIsTTY) + return io } func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { From a00d927970029f8d23c72bcaa40b0897366d6002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 21 Aug 2020 17:54:58 +0200 Subject: [PATCH 03/70] Add `release download`, upload files on create, upload retrying --- api/client.go | 7 +- api/client_test.go | 15 ++- pkg/cmd/release/create/create.go | 121 +++++++++++------ pkg/cmd/release/create/http.go | 73 +++++++++++ pkg/cmd/release/download/download.go | 160 +++++++++++++++++++++++ pkg/cmd/release/release.go | 2 + pkg/cmd/release/shared/fetch.go | 74 +++++++++++ pkg/cmd/release/shared/upload.go | 187 +++++++++++++++++++++++++++ pkg/cmd/release/upload/http.go | 121 ----------------- pkg/cmd/release/upload/upload.go | 90 +------------ pkg/cmd/release/view/http.go | 51 -------- pkg/cmd/release/view/view.go | 51 +++++++- pkg/iostreams/color.go | 78 +++++++++++ pkg/iostreams/iostreams.go | 6 +- 14 files changed, 727 insertions(+), 309 deletions(-) create mode 100644 pkg/cmd/release/create/http.go create mode 100644 pkg/cmd/release/download/download.go create mode 100644 pkg/cmd/release/shared/fetch.go create mode 100644 pkg/cmd/release/shared/upload.go delete mode 100644 pkg/cmd/release/upload/http.go delete mode 100644 pkg/cmd/release/view/http.go create mode 100644 pkg/iostreams/color.go diff --git a/api/client.go b/api/client.go index e30eb18b1..3ee54a406 100644 --- a/api/client.go +++ b/api/client.go @@ -190,10 +190,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]) - } + 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) diff --git a/api/client_test.go b/api/client_test.go index 063d4648c..d2925bd20 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -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) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index afb30e829..879081b92 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -1,15 +1,13 @@ -package list +package create import ( - "bytes" - "encoding/json" "fmt" "io/ioutil" "net/http" + "strings" - "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" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/spf13/cobra" @@ -20,7 +18,18 @@ type CreateOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - TagName string + TagName string + Target string + Name string + Body string + BodyProvided bool + Draft bool + Prerelease bool + + Assets []*shared.AssetForUpload + + // maximum number of simultaneous uploads + Concurrency int } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -29,16 +38,41 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co HttpClient: f.HttpClient, } + var notesFile string + cmd := &cobra.Command{ - Use: "create ", + Use: "create [...]", Short: "Create a new release", - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), 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 + + 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) } @@ -46,6 +80,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co }, } + 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 } @@ -61,47 +102,45 @@ func createRun(opts *CreateOptions) error { } params := map[string]interface{}{ - "tag_name": opts.TagName, + "tag_name": opts.TagName, + "draft": opts.Draft, + "prerelease": opts.Prerelease, + "name": opts.Name, + "body": opts.Body, + } + if opts.Target != "" { + params["target_commitish"] = opts.Target } - bodyBytes, err := json.Marshal(params) + hasAssets := len(opts.Assets) > 0 + if hasAssets { + params["draft"] = true + } + + newRelease, err := createRelease(httpClient, baseRepo, params) if err != nil { return err } - path := fmt.Sprintf("repos/%s/%s/releases", baseRepo.RepoOwner(), baseRepo.RepoName()) - url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path - req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) - if err != nil { - return err - } + if hasAssets { + uploadURL := newRelease.UploadURL + if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { + uploadURL = uploadURL[:idx] + } - req.Header.Set("Content-Type", "application/json; charset=utf-8") + opts.IO.StartProgressIndicator() + err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.StopProgressIndicator() + 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) - } - - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - - var newRelease struct { - HTMLURL string `json:"html_url"` - AssetsURL string `json:"assets_url"` - } - - err = json.Unmarshal(b, &newRelease) - if err != nil { - return err + if !opts.Draft { + err := publishRelease(httpClient, newRelease.URL) + if err != nil { + return err + } + } } fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL) diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go new file mode 100644 index 000000000..684772c12 --- /dev/null +++ b/pkg/cmd/release/create/http.go @@ -0,0 +1,73 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "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) error { + req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBufferString(`{"draft":false}`)) + if err != nil { + return err + } + + req.Header.Add("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + _, err = io.Copy(ioutil.Discard, resp.Body) + return err +} diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go new file mode 100644 index 000000000..55bf8e3ce --- /dev/null +++ b/pkg/cmd/release/download/download.go @@ -0,0 +1,160 @@ +package download + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + + "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 + FilePattern 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 []", + Short: "Download release assets", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.TagName = args[0] + if len(args) > 1 { + opts.FilePattern = args[1] + } + + opts.Concurrency = 5 + + if runF != nil { + return runF(opts) + } + return downloadRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Destination, "destination", "C", ".", "The directory to download files into") + + 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 + } + + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + var toDownload []shared.ReleaseAsset + for _, a := range release.Assets { + if opts.FilePattern != "" { + if isMatch, err := filepath.Match(opts.FilePattern, a.Name); err != nil || !isMatch { + continue + } + } + toDownload = append(toDownload, a) + } + + 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 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)) + + for w := 1; w <= numWorkers; w++ { + go func() { + for a := range jobs { + results <- downloadAsset(httpClient, a.URL, 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 +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index dedf38afe..06289b81b 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -2,6 +2,7 @@ package release import ( cmdCreate "github.com/cli/cli/pkg/cmd/release/create" + 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" @@ -21,6 +22,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdCreate.NewCmdCreate(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)) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go new file mode 100644 index 000000000..3d568ab2d --- /dev/null +++ b/pkg/cmd/release/shared/fetch.go @@ -0,0 +1,74 @@ +package shared + +import ( + "encoding/json" + "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"` + PublishedAt time.Time `json:"published_at"` + + URL 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 + URL string +} + +func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) { + // FIXME: this doesn't find draft releases + 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 + } + + 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 release Release + err = json.Unmarshal(b, &release) + if err != nil { + return nil, err + } + + return &release, nil +} diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go new file mode 100644 index 000000000..a8174f467 --- /dev/null +++ b/pkg/cmd/release/shared/upload.go @@ -0,0 +1,187 @@ +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 := path.Ext(fn) + t := mime.TypeByExtension(ext) + + if t == "" { + return "application/octet-stream" + } + return t +} + +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)) + + 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 +} diff --git a/pkg/cmd/release/upload/http.go b/pkg/cmd/release/upload/http.go deleted file mode 100644 index 8b66c690d..000000000 --- a/pkg/cmd/release/upload/http.go +++ /dev/null @@ -1,121 +0,0 @@ -package upload - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - - "github.com/cli/cli/api" - "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/internal/ghrepo" -) - -type Release struct { - UploadURL string `json:"upload_url"` - Assets []ReleaseAsset -} - -type ReleaseAsset struct { - Name string - State string - URL string -} - -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 - } - - 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 release Release - err = json.Unmarshal(b, &release) - if err != nil { - return nil, err - } - - return &release, nil -} - -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() - - req, err := http.NewRequest("POST", u.String(), asset.Data) - 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 -} diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index beefb55d5..17af4584d 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -2,13 +2,11 @@ package upload import ( "fmt" - "io" "net/http" - "os" - "path/filepath" "strings" "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" @@ -20,24 +18,13 @@ type UploadOptions struct { BaseRepo func() (ghrepo.Interface, error) TagName string - Assets []*AssetForUpload + Assets []*shared.AssetForUpload // maximum number of simultaneous uploads Concurrency int OverwriteExisting bool } -type AssetForUpload struct { - Name string - Label string - - Data io.ReadCloser - Size int64 - MIMEType string - - ExistingURL string -} - func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Command { opts := &UploadOptions{ IO: f.IOStreams, @@ -55,7 +42,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co opts.TagName = args[0] var err error - opts.Assets, err = assetsFromArgs(args[1:]) + opts.Assets, err = shared.AssetsFromArgs(args[1:]) if err != nil { return err } @@ -74,32 +61,6 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co return cmd } -func assetsFromArgs(args []string) (assets []*AssetForUpload, err error) { - for _, fn := range args { - var f *os.File - var fi os.FileInfo - - f, err = os.Open(fn) - if err != nil { - return - } - fi, err = f.Stat() - if err != nil { - return - } - - assets = append(assets, &AssetForUpload{ - Data: f, - Size: fi.Size(), - Name: filepath.Base(fn), - Label: "", - // TODO: infer content type from file extension - MIMEType: "application/octet-stream", - }) - } - return -} - func uploadRun(opts *UploadOptions) error { httpClient, err := opts.HttpClient() if err != nil { @@ -111,7 +72,7 @@ func uploadRun(opts *UploadOptions) error { return err } - release, err := fetchRelease(httpClient, baseRepo, opts.TagName) + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) if err != nil { return err } @@ -137,50 +98,11 @@ func uploadRun(opts *UploadOptions) error { } opts.IO.StartProgressIndicator() - err = concurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) - opts.IO.EndProgressIndicator() + err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.StopProgressIndicator() if err != nil { return err } return nil } - -func concurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error { - jobs := make(chan AssetForUpload, len(assets)) - results := make(chan error, 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 -} - -func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error { - if a.ExistingURL != "" { - err := deleteAsset(httpClient, a.ExistingURL) - if err != nil { - return err - } - } - - defer a.Data.Close() - _, err := uploadAsset(httpClient, uploadURL, a) - return err -} diff --git a/pkg/cmd/release/view/http.go b/pkg/cmd/release/view/http.go deleted file mode 100644 index 7422bf40c..000000000 --- a/pkg/cmd/release/view/http.go +++ /dev/null @@ -1,51 +0,0 @@ -package view - -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 { - TagName string - Name string - Description string - URL string - IsDraft bool - IsPrerelease bool - PublishedAt time.Time - - Author struct { - Login string - } - - ReleaseAssets struct { - Nodes []struct { - Name string - Size int - } - } `graphql:"releaseAssets(first: 100)"` -} - -func fetchRelease(httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { - var query struct { - Repository struct { - Release *Release `graphql:"release(tagName: $tagName)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "name": githubv4.String(repo.RepoName()), - "tagName": githubv4.String(tagName), - } - - gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient) - err := gql.QueryNamed(context.Background(), "RepositoryReleaseByTag", &query, variables) - return query.Repository.Release, err -} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 054d0021d..9ac43a7c3 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -3,8 +3,10 @@ package view import ( "fmt" "net/http" + "time" "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" @@ -56,20 +58,61 @@ func viewRun(opts *ViewOptions) error { return err } - release, err := fetchRelease(httpClient, baseRepo, opts.TagName) + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) if err != nil { return err } - fmt.Fprintf(opts.IO.Out, "%s\n", release.TagName) + iofmt := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Yellow("Pre-release")) + } + fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt))))) - renderedDescription, err := utils.RenderMarkdown(release.Description) + renderedDescription, err := utils.RenderMarkdown(release.Body) if err != nil { return err } fmt.Fprintln(opts.IO.Out, renderedDescription) - fmt.Fprintf(opts.IO.Out, "%s\n", release.URL) + if len(release.Assets) > 0 { + fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold("Assets")) + table := utils.NewTablePrinter(opts.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(opts.IO.Out, "\n") + } + + fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL))) 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("%.2f KiB", kb) + } + + mb := float64(kb) / 1024 + if mb < 1024 { + return fmt.Sprintf("%.2f MiB", mb) + } + + gb := float64(kb) / 1024 + return fmt.Sprintf("%.2f GiB", gb) +} diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go new file mode 100644 index 000000000..7643984a1 --- /dev/null +++ b/pkg/iostreams/color.go @@ -0,0 +1,78 @@ +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) +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 25d447371..7084fe092 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -93,7 +93,7 @@ func (s *IOStreams) StartProgressIndicator() { s.progressIndicator = sp } -func (s *IOStreams) EndProgressIndicator() { +func (s *IOStreams) StopProgressIndicator() { if s.progressIndicator == nil { return } @@ -124,6 +124,10 @@ func (s *IOStreams) TerminalWidth() int { return defaultWidth } +func (s *IOStreams) ColorScheme() *ColorScheme { + return NewColorScheme(s.IsStdoutTTY()) +} + func System() *IOStreams { var out io.Writer = os.Stdout stdoutIsTTY := isTerminal(os.Stdout) From 33fd6b1149bc70fecfff8aec1398e81527fd3a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 21 Aug 2020 17:57:00 +0200 Subject: [PATCH 04/70] Change API authentication to allow asset downloads We install an HTTP middleware that adds the "Authorization" header on every HTTP request. However, our asset download process might redirect to a 3rd-party host (Amazon S3) and we want to allow those requests but not require that they are authenticated. Furthermore, we need the ability to specify the `Accept` request header without it being overwritten by middleware, so now middleware only adds headers that are not present in a request. --- api/client.go | 11 +++++++++-- pkg/cmd/factory/http.go | 17 ++--------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/api/client.go b/api/client.go index 3ee54a406..8a7445d8d 100644 --- a/api/client.go +++ b/api/client.go @@ -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 len(req.Header.Values(name)) == 0 { + 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 len(req.Header.Values(name)) > 0 { + 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) }} } diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index b113100f7..4d9bfca50 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -1,7 +1,6 @@ package factory import ( - "errors" "fmt" "net/http" "os" @@ -30,20 +29,8 @@ func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, s hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) token, err := cfg.Get(hostname, "oauth_token") - if token == "" { - var notFound *config.NotFoundError - // TODO: check if stdout is TTY too - if errors.As(err, ¬Found) && io.IsStdinTTY() { - // interactive OAuth flow - token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required", nil) - } - if err != nil { - return "", err - } - if token == "" { - // TODO: instruct user how to manually authenticate - return "", fmt.Errorf("authentication required for %s", hostname) - } + if err != nil || token == "" { + return "", nil } return fmt.Sprintf("token %s", token), nil From cf46ae2db706a6a6b65bd534187fadb66d77d392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 21 Aug 2020 18:00:10 +0200 Subject: [PATCH 05/70] Log larger response bodies with `DEBUG=api` --- api/client.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/api/client.go b/api/client.go index 8a7445d8d..ec0190fc8 100644 --- a/api/client.go +++ b/api/client.go @@ -75,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) { From 4f6021a56dc6f77f4b1241494d0d232f456bf51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 21 Aug 2020 18:27:58 +0200 Subject: [PATCH 06/70] Add interactive flow for `release create` --- pkg/cmd/release/create/create.go | 75 +++++++++++++++++++++++++++++++- pkg/iostreams/iostreams.go | 4 ++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 879081b92..dea98b6e2 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -6,16 +6,21 @@ import ( "net/http" "strings" + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/internal/config" "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/cli/cli/pkg/surveyext" "github.com/spf13/cobra" ) type CreateOptions struct { - HttpClient func() (*http.Client, error) IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) TagName string @@ -28,6 +33,9 @@ type CreateOptions struct { Assets []*shared.AssetForUpload + // for interactive flow + SubmitAction string + // maximum number of simultaneous uploads Concurrency int } @@ -36,6 +44,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Config: f.Config, } var notesFile string @@ -101,6 +110,68 @@ func createRun(opts *CreateOptions) error { return err } + if !opts.BodyProvided && opts.IO.CanPrompt() { + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + qs := []*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{ + Message: "Title", + Default: opts.Name, + }, + }, + { + Name: "body", + Prompt: &surveyext.GhEditor{ + BlankAllowed: true, + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Release notes", + FileName: "*.md", + Default: opts.Body, + HideDefault: true, + }, + }, + }, + { + 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 + } + } + params := map[string]interface{}{ "tag_name": opts.TagName, "draft": opts.Draft, @@ -113,6 +184,8 @@ func createRun(opts *CreateOptions) error { } hasAssets := len(opts.Assets) > 0 + + // Avoid publishing the release until all assets have finished uploading if hasAssets { params["draft"] = true } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 7084fe092..d2728e468 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -84,6 +84,10 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) CanPrompt() bool { + return s.IsStdinTTY() && s.IsStdoutTTY() +} + func (s *IOStreams) StartProgressIndicator() { if !s.progressIndicatorEnabled { return From f7d0988799853933e2967a2c2569ed9b8f6bfbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 25 Aug 2020 20:20:44 +0200 Subject: [PATCH 07/70] Offer different editor modes during `release create` --- pkg/cmd/release/create/create.go | 148 ++++++++++++++++++++++++++++--- pkg/cmd/root/help.go | 15 +--- pkg/cmd/root/help_test.go | 44 --------- pkg/surveyext/editor.go | 58 +----------- pkg/surveyext/editor_manual.go | 80 +++++++++++++++++ pkg/text/indent.go | 15 ++++ pkg/text/indent_test.go | 47 ++++++++++ 7 files changed, 284 insertions(+), 123 deletions(-) create mode 100644 pkg/surveyext/editor_manual.go create mode 100644 pkg/text/indent.go create mode 100644 pkg/text/indent_test.go diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index dea98b6e2..4010db407 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -1,19 +1,24 @@ package create import ( + "bytes" "fmt" "io/ioutil" "net/http" + "os" + "os/exec" "strings" "github.com/AlecAivazis/survey/v2" "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" ) @@ -35,6 +40,11 @@ type CreateOptions struct { // 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 @@ -56,6 +66,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co 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] @@ -116,6 +127,35 @@ func createRun(opts *CreateOptions) error { 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", @@ -125,18 +165,46 @@ func createRun(opts *CreateOptions) error { }, }, { - Name: "body", - Prompt: &surveyext.GhEditor{ - BlankAllowed: true, - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Release notes", - FileName: "*.md", - Default: opts.Body, - HideDefault: true, - }, + 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{ @@ -169,6 +237,8 @@ func createRun(opts *CreateOptions) error { opts.Draft = true case "Cancel": return cmdutil.SilentError + default: + return fmt.Errorf("invalid action: %v", opts.SubmitAction) } } @@ -220,3 +290,61 @@ func createRun(opts *CreateOptions) error { 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") +} diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 5570fbaaa..019ec53cd 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -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 diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index cbda48fe8..d24a84a0d 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -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) - } - }) - } -} diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go index 9c1fca2e1..038cc9036 100644 --- a/pkg/surveyext/editor.go +++ b/pkg/surveyext/editor.go @@ -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 { diff --git a/pkg/surveyext/editor_manual.go b/pkg/surveyext/editor_manual.go new file mode 100644 index 000000000..faa2345ad --- /dev/null +++ b/pkg/surveyext/editor_manual.go @@ -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 +} diff --git a/pkg/text/indent.go b/pkg/text/indent.go new file mode 100644 index 000000000..f63539778 --- /dev/null +++ b/pkg/text/indent.go @@ -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) +} diff --git a/pkg/text/indent_test.go b/pkg/text/indent_test.go new file mode 100644 index 000000000..c93310114 --- /dev/null +++ b/pkg/text/indent_test.go @@ -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) + } + }) + } +} From 9829a4dc848a3d6444b265838e4e513a2495a4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 25 Aug 2020 20:29:24 +0200 Subject: [PATCH 08/70] Update `release list` output to contain badges --- pkg/cmd/release/list/http.go | 1 + pkg/cmd/release/list/list.go | 42 +++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/list/http.go b/pkg/cmd/release/list/http.go index 778256ed7..763963ff1 100644 --- a/pkg/cmd/release/list/http.go +++ b/pkg/cmd/release/list/http.go @@ -16,6 +16,7 @@ type Release struct { TagName string IsDraft bool IsPrerelease bool + CreatedAt time.Time PublishedAt time.Time } diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index 61a9c097a..b2e1b88ff 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -1,6 +1,7 @@ package list import ( + "fmt" "net/http" "time" @@ -64,10 +65,45 @@ func listRun(opts *ListOptions) error { now := time.Now() table := utils.NewTablePrinter(opts.IO) + iofmt := opts.IO.ColorScheme() + seenLatest := false for _, rel := range releases { - table.AddField(rel.TagName, nil, nil) - table.AddField(text.ReplaceExcessiveWhitespace(rel.Name), nil, nil) - table.AddField(utils.FuzzyAgo(now.Sub(rel.PublishedAt)), nil, nil) + 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.String() + if table.IsTTY() { + publishedAt = utils.FuzzyAgo(now.Sub(pubDate)) + } + table.AddField(publishedAt, nil, iofmt.Gray) table.EndRow() } err = table.Render() From d0c2c81f2d1e9d70945c5a99efdecf3875011b97 Mon Sep 17 00:00:00 2001 From: AliabbasMerchant Date: Thu, 27 Aug 2020 13:39:21 +0530 Subject: [PATCH 09/70] Interactive template selection test for issue create --- pkg/cmd/issue/create/create.go | 7 +- pkg/cmd/issue/create/create_test.go | 71 ++++++++++++++++++- .../.github/ISSUE_TEMPLATE/bug_report.md | 9 +++ .../.github/ISSUE_TEMPLATE/enhancement.md | 9 +++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 8d47e4e27..4190fba3f 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -23,6 +23,8 @@ type CreateOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + RootDirOverride string + RepoOverride string WebMode bool @@ -94,9 +96,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") } } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index b483d090b..9a3cd119f 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -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 { @@ -126,6 +134,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`, "./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) diff --git a/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md b/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..d57082bcf --- /dev/null +++ b/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,9 @@ +--- +name: Bug report +about: Report a bug or unexpected behavior +title: Bug Report +labels: bug + +--- + +I wanna report a bug \ No newline at end of file diff --git a/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md b/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 000000000..0fe84afa3 --- /dev/null +++ b/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,9 @@ +--- +name: Submit a request +about: Propose an improvement +title: Enhancement Proposal +labels: enhancement + +--- + +I have a suggestion for an enhancement \ No newline at end of file From d43d5e0bc908684a53833b816136dc32f6fdf3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 28 Aug 2020 17:41:00 +0200 Subject: [PATCH 10/70] Add `release delete` --- api/queries_repo.go | 14 ++++ pkg/cmd/release/create/create.go | 2 +- pkg/cmd/release/delete/delete.go | 119 +++++++++++++++++++++++++++++++ pkg/cmd/release/release.go | 2 + pkg/cmd/release/shared/fetch.go | 68 ++++++++++++++++-- pkg/cmd/release/view/view.go | 6 +- pkg/iostreams/color.go | 8 +++ 7 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/release/delete/delete.go diff --git a/api/queries_repo.go b/api/queries_repo.go index e19bc1cf6..0f537426f 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -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 { diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 4010db407..4367b94ae 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -279,7 +279,7 @@ func createRun(opts *CreateOptions) error { } if !opts.Draft { - err := publishRelease(httpClient, newRelease.URL) + err := publishRelease(httpClient, newRelease.APIURL) if err != nil { return err } diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go new file mode 100644 index 000000000..0c78357c5 --- /dev/null +++ b/pkg/cmd/release/delete/delete.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 06289b81b..facd0a1e3 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -2,6 +2,7 @@ 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" @@ -22,6 +23,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { 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)) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 3d568ab2d..649c93325 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -2,6 +2,7 @@ package shared import ( "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" @@ -18,9 +19,10 @@ type Release struct { Body string `json:"body"` IsDraft bool `json:"draft"` IsPrerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` PublishedAt time.Time `json:"published_at"` - URL string `json:"url"` + APIURL string `json:"url"` UploadURL string `json:"upload_url"` HTMLURL string `json:"html_url"` Assets []ReleaseAsset @@ -37,8 +39,8 @@ type ReleaseAsset struct { URL string } +// FetchRelease finds a repository release by its tagName. func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) { - // FIXME: this doesn't find draft releases 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) @@ -46,16 +48,21 @@ func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName st 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 { + 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) } @@ -72,3 +79,52 @@ func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName st return &release, nil } + +// FindDraftRelease interates over all releases in a repository until it finds one 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.TagName == tagName { + return &r, nil + } + } + + if len(releases) < perPage { + break + } + page++ + } + + return nil, errors.New("release not found") +} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 9ac43a7c3..c1458355b 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -70,7 +70,11 @@ func viewRun(opts *ViewOptions) error { } else if release.IsPrerelease { fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Yellow("Pre-release")) } - fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt))))) + if release.IsDraft { + fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt))))) + } else { + fmt.Fprintf(opts.IO.Out, "%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 { diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 7643984a1..ed4b32a8f 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -76,3 +76,11 @@ func (c *ColorScheme) Blue(t string) string { } return blue(t) } + +func (c *ColorScheme) SuccessIcon() string { + return c.Green("✓") +} + +func (c *ColorScheme) WarningIcon() string { + return c.Yellow("✓") +} From 9b45112e69a7b9644c782a9c18a99fc9b607c2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 28 Aug 2020 19:26:16 +0200 Subject: [PATCH 11/70] Add tests for `release list/delete` --- pkg/cmd/release/delete/delete_test.go | 160 ++++++++++++++++++++++++ pkg/cmd/release/list/list_test.go | 169 ++++++++++++++++++++++++++ pkg/iostreams/color.go | 2 +- pkg/iostreams/iostreams.go | 2 +- 4 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/release/delete/delete_test.go create mode 100644 pkg/cmd/release/list/list_test.go diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go new file mode 100644 index 000000000..87c27adc6 --- /dev/null +++ b/pkg/cmd/release/delete/delete_test.go @@ -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_NewCmdReady(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want DeleteOptions + wantErr string + }{ + { + name: "number 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()) + }) + } +} diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go new file mode 100644 index 000000000..c6e0f6c85 --- /dev/null +++ b/pkg/cmd/release/list/list_test.go @@ -0,0 +1,169 @@ +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) { + 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: ``, + }, + } + 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) + + relativeTime := 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" + } + ] + } } } }`, relativeTime.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()) + }) + } +} diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index ed4b32a8f..6bbc429c5 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -82,5 +82,5 @@ func (c *ColorScheme) SuccessIcon() string { } func (c *ColorScheme) WarningIcon() string { - return c.Yellow("✓") + return c.Yellow("!") } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index aa119164f..a5164e32d 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -129,7 +129,7 @@ func (s *IOStreams) TerminalWidth() int { } func (s *IOStreams) ColorScheme() *ColorScheme { - return NewColorScheme(s.IsStdoutTTY()) + return NewColorScheme(s.ColorEnabled()) } func System() *IOStreams { From 8dd93e1748438bf2b52ebd2e3950986db63568d9 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 19 Aug 2020 21:27:56 -0500 Subject: [PATCH 12/70] gh pr checks A basic first pass on gh pr checks that shows all check runs for a given PR's latest commit. --- api/queries_pr.go | 68 +++++- pkg/cmd/pr/checks/checks.go | 211 +++++++++++++++++++ pkg/cmd/pr/checks/checks_test.go | 200 ++++++++++++++++++ pkg/cmd/pr/checks/fixtures/allPassing.json | 41 ++++ pkg/cmd/pr/checks/fixtures/someFailing.json | 41 ++++ pkg/cmd/pr/checks/fixtures/somePending.json | 41 ++++ pkg/cmd/pr/checks/fixtures/withStatuses.json | 38 ++++ pkg/cmd/pr/pr.go | 2 + utils/utils.go | 8 + 9 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 pkg/cmd/pr/checks/checks.go create mode 100644 pkg/cmd/pr/checks/checks_test.go create mode 100644 pkg/cmd/pr/checks/fixtures/allPassing.json create mode 100644 pkg/cmd/pr/checks/fixtures/someFailing.json create mode 100644 pkg/cmd/pr/checks/fixtures/somePending.json create mode 100644 pkg/cmd/pr/checks/fixtures/withStatuses.json diff --git a/api/queries_pr.go b/api/queries_pr.go index f2b398adf..6df1b91c9 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -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 } } } @@ -272,9 +280,11 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu contexts(last: 100) { nodes { ...on StatusContext { + context state } ...on CheckRun { + name status conclusion } @@ -418,8 +428,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 +558,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 diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go new file mode 100644 index 000000000..f42c3ec45 --- /dev/null +++ b/pkg/cmd/pr/checks/checks.go @@ -0,0 +1,211 @@ +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 + } + + outputs := []output{} + + for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { + mark := "" + bucket := "" + state := c.State + if state == "" { + if c.Status == "COMPLETED" { + state = c.Conclusion + } else { + state = c.Status + } + } + switch state { + case "SUCCESS", "NEUTRAL", "SKIPPED": + mark = utils.GreenCheck() + passing++ + bucket = "pass" + case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": + mark = utils.RedX() + failing++ + bucket = "fail" + case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE": + mark = utils.YellowDash() + 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}) + } + + sort.Slice(outputs, func(i, j int) bool { + if outputs[i].bucket == outputs[j].bucket { + return outputs[i].name < outputs[j].name + } else { + if outputs[i].bucket == "fail" { + return true + } else if outputs[i].bucket == "pending" && outputs[j].bucket == "success" { + return true + } + } + + return false + }) + + tp := utils.NewTablePrinter(opts.IO) + + for _, o := range outputs { + if opts.IO.IsStdoutTTY() { + tp.AddField(o.mark, nil, nil) + 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) + } + + return tp.Render() +} diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go new file mode 100644 index 000000000..152f5aea9 --- /dev/null +++ b/pkg/cmd/pr/checks/checks_test.go @@ -0,0 +1,200 @@ +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 + }{ + { + 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", + }, + { + 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", + }, + { + 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", + }, + { + 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", + }, + { + 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", + }, + { + 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", + }, + } + + 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) + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/pr/checks/fixtures/allPassing.json b/pkg/cmd/pr/checks/fixtures/allPassing.json new file mode 100644 index 000000000..c75e3fc29 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -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" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json new file mode 100644 index 000000000..0e53cdb79 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -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" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json new file mode 100644 index 000000000..6e36a5cd3 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -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" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json new file mode 100644 index 000000000..0ce8b9c66 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -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" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index a4b746656..e06eb8059 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -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 } diff --git a/utils/utils.go b/utils/utils.go index cee56bf58..208b7a44e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -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") +} From d0d9cc2f9b3910e6d1f740e174b0355114c579a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Sep 2020 14:16:59 +0200 Subject: [PATCH 13/70] View latest release if no tag provided --- pkg/cmd/release/shared/fetch.go | 33 +++++++++++++++++++++++++++++++++ pkg/cmd/release/view/view.go | 23 +++++++++++++++++------ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 649c93325..15142df1e 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -80,6 +80,39 @@ func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName st 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 interates over all releases in a repository until it finds one 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()) diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index c1458355b..ab1d2a812 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -28,14 +28,16 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd := &cobra.Command{ - Use: "view ", + Use: "view []", Short: "View information about a release", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.TagName = args[0] + if len(args) > 0 { + opts.TagName = args[0] + } if runF != nil { return runF(opts) @@ -58,9 +60,18 @@ func viewRun(opts *ViewOptions) error { return err } - release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) - 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 + } } iofmt := opts.IO.ColorScheme() From 9ef1791fdb03b3ef38314c0990c340c20f1d417c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Sep 2020 14:17:19 +0200 Subject: [PATCH 14/70] Provide machine-readable `release view` output --- pkg/cmd/release/view/view.go | 57 +++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index ab1d2a812..e6d8de79b 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "io" "net/http" "time" @@ -74,28 +75,44 @@ func viewRun(opts *ViewOptions) error { } } - iofmt := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold(release.TagName)) + 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(opts.IO.Out, "%s • ", iofmt.Red("Draft")) + fmt.Fprintf(w, "%s • ", iofmt.Red("Draft")) } else if release.IsPrerelease { - fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Yellow("Pre-release")) + fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release")) } if release.IsDraft { - fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt))))) + 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(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt))))) + 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(opts.IO.Out, renderedDescription) + fmt.Fprintln(w, renderedDescription) if len(release.Assets) > 0 { - fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold("Assets")) - table := utils.NewTablePrinter(opts.IO) + 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) @@ -105,11 +122,29 @@ func viewRun(opts *ViewOptions) error { if err != nil { return err } - fmt.Fprint(opts.IO.Out, "\n") + fmt.Fprint(w, "\n") } - fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL))) + 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) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt) + } + 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 } From 0f42c7af4d014b8d711241630099fadd1b588a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Sep 2020 18:56:30 +0200 Subject: [PATCH 15/70] Add tests for release create, delete, download, view --- pkg/cmd/release/create/create_test.go | 333 ++++++++++++++++++++++ pkg/cmd/release/delete/delete_test.go | 4 +- pkg/cmd/release/download/download.go | 15 +- pkg/cmd/release/download/download_test.go | 229 +++++++++++++++ pkg/cmd/release/shared/fetch.go | 8 +- pkg/cmd/release/shared/upload.go | 23 +- pkg/cmd/release/shared/upload_test.go | 69 +++++ pkg/cmd/release/upload/upload.go | 2 +- pkg/cmd/release/view/view.go | 18 +- pkg/cmd/release/view/view_test.go | 277 ++++++++++++++++++ 10 files changed, 962 insertions(+), 16 deletions(-) create mode 100644 pkg/cmd/release/create/create_test.go create mode 100644 pkg/cmd/release/download/download_test.go create mode 100644 pkg/cmd/release/shared/upload_test.go create mode 100644 pkg/cmd/release/view/view_test.go diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go new file mode 100644 index 000000000..f9fad4f81 --- /dev/null +++ b/pkg/cmd/release/create/create_test.go @@ -0,0 +1,333 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "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: "v1.2.3 -F" + 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: ``, + }, + } + 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, `{ + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`)) + + 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) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go index 87c27adc6..baeb1a379 100644 --- a/pkg/cmd/release/delete/delete_test.go +++ b/pkg/cmd/release/delete/delete_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_NewCmdReady(t *testing.T) { +func Test_NewCmdDelete(t *testing.T) { tests := []struct { name string args string @@ -25,7 +25,7 @@ func Test_NewCmdReady(t *testing.T) { wantErr string }{ { - name: "number argument", + name: "version argument", args: "v1.2.3", isTTY: true, want: DeleteOptions{ diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 55bf8e3ce..0521fe12e 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -56,7 +56,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr }, } - cmd.Flags().StringVarP(&opts.Destination, "destination", "C", ".", "The directory to download files into") + cmd.Flags().StringVarP(&opts.Destination, "dir", "C", ".", "The directory to download files into") return cmd } @@ -87,6 +87,13 @@ func downloadRun(opts *DownloadOptions) error { 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 { @@ -108,10 +115,14 @@ func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, d 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.URL, filepath.Join(destDir, a.Name)) + results <- downloadAsset(httpClient, a.APIURL, filepath.Join(destDir, a.Name)) } }() } diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go new file mode 100644 index 000000000..8a6956bb1 --- /dev/null +++ b/pkg/cmd/release/download/download_test.go @@ -0,0 +1,229 @@ +package download + +import ( + "bytes" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "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", + FilePattern: "", + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "version and file pattern", + args: "v1.2.3 *.tgz", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePattern: "*.tgz", + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "version and destination", + args: "v1.2.3 -C tmp/assets", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePattern: "", + Destination: "tmp/assets", + Concurrency: 5, + }, + }, + { + 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, _, _, _ := 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.FilePattern, opts.FilePattern) + 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", + FilePattern: "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", + FilePattern: "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() { + files = append(files, "."+strings.TrimPrefix(p, dir)) + } + return err + }) + return files, err +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 15142df1e..7e233fb6b 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -33,10 +33,10 @@ type Release struct { } type ReleaseAsset struct { - Name string - Size int64 - State string - URL string + Name string + Size int64 + State string + APIURL string `json:"url"` } // FetchRelease finds a repository release by its tagName. diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index a8174f467..bdb0c837a 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -56,9 +56,24 @@ func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) { } func typeForFilename(fn string) string { - ext := path.Ext(fn) - t := mime.TypeByExtension(ext) + fn = strings.ToLower(fn) + if strings.HasSuffix(fn, ".tar.gz") { + return "application/x-gtar" + } + ext := path.Ext(fn) + switch ext { + case ".tgz": + return "application/x-gtar" + case ".bz2": + return "application/x-bzip2" + case ".dmg": + return "application/x-apple-diskimage" + case ".rpm": + return "application/x-rpm" + } + + t := mime.TypeByExtension(ext) if t == "" { return "application/octet-stream" } @@ -73,6 +88,10 @@ func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, 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 { diff --git a/pkg/cmd/release/shared/upload_test.go b/pkg/cmd/release/shared/upload_test.go new file mode 100644 index 000000000..da1aa8918 --- /dev/null +++ b/pkg/cmd/release/shared/upload_test.go @@ -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) + } + }) + } +} diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index 17af4584d..eb2d62151 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -86,7 +86,7 @@ func uploadRun(opts *UploadOptions) error { for _, a := range opts.Assets { for _, ea := range release.Assets { if ea.Name == a.Name { - a.ExistingURL = ea.URL + a.ExistingURL = ea.APIURL existingNames = append(existingNames, ea.Name) break } diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index e6d8de79b..6b036b12f 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/cli/cli/internal/ghrepo" @@ -155,14 +156,21 @@ func humanFileSize(s int64) string { kb := float64(s) / 1024 if kb < 1024 { - return fmt.Sprintf("%.2f KiB", kb) + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) } - mb := float64(kb) / 1024 + mb := kb / 1024 if mb < 1024 { - return fmt.Sprintf("%.2f MiB", mb) + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) } - gb := float64(kb) / 1024 - return fmt.Sprintf("%.2f GiB", gb) + 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] } diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go new file mode 100644 index 000000000..b040c2565 --- /dev/null +++ b/pkg/cmd/release/view/view_test.go @@ -0,0 +1,277 @@ +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", + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + want: ViewOptions{ + TagName: "", + }, + }, + } + 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) + }) + } +} + +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-31 15:44:24 +0200 CEST + published: 2020-08-31 15:44:24 +0200 CEST + 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) + } + }) + } +} From 4976bd75e1fbef4b0c7b0621aa07047ad0320eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Sep 2020 19:08:21 +0200 Subject: [PATCH 16/70] Fix date-based release output and tests --- pkg/cmd/release/list/list.go | 2 +- pkg/cmd/release/list/list_test.go | 24 ++++++++++++++++++++++-- pkg/cmd/release/view/view.go | 4 ++-- pkg/cmd/release/view/view_test.go | 4 ++-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index b2e1b88ff..caaab772d 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -99,7 +99,7 @@ func listRun(opts *ListOptions) error { if rel.PublishedAt.IsZero() { pubDate = rel.CreatedAt } - publishedAt := pubDate.String() + publishedAt := pubDate.Format(time.RFC3339) if table.IsTTY() { publishedAt = utils.FuzzyAgo(now.Sub(pubDate)) } diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go index c6e0f6c85..8f17dc6ce 100644 --- a/pkg/cmd/release/list/list_test.go +++ b/pkg/cmd/release/list/list_test.go @@ -75,6 +75,9 @@ func Test_NewCmdList(t *testing.T) { } 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 @@ -97,6 +100,20 @@ func Test_listRun(t *testing.T) { `), 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) { @@ -105,7 +122,10 @@ func Test_listRun(t *testing.T) { io.SetStdinTTY(tt.isTTY) io.SetStderrTTY(tt.isTTY) - relativeTime := time.Now().Add(time.Duration(-24) * time.Hour) + 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(` @@ -144,7 +164,7 @@ func Test_listRun(t *testing.T) { "publishedAt": "%[1]s" } ] - } } } }`, relativeTime.Format(time.RFC3339)))) + } } } }`, createdAt.Format(time.RFC3339)))) tt.opts.IO = io tt.opts.HttpClient = func() (*http.Client, error) { diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 6b036b12f..6de42abc1 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -136,9 +136,9 @@ func renderReleasePlain(w io.Writer, release *shared.Release) error { 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) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) if !release.IsDraft { - fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt) + 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 { diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index b040c2565..df6ee0860 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -155,8 +155,8 @@ func Test_viewRun(t *testing.T) { draft: false prerelease: false author: MonaLisa - created: 2020-08-31 15:44:24 +0200 CEST - published: 2020-08-31 15:44:24 +0200 CEST + 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 From 6933c381bfa2dd8ff95221b4dbfa2518b0cf10f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 1 Sep 2020 19:12:02 +0200 Subject: [PATCH 17/70] Restore Go 1.13 compatibility --- api/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/client.go b/api/client.go index ec0190fc8..d4a32d87d 100644 --- a/api/client.go +++ b/api/client.go @@ -45,7 +45,7 @@ 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) { - if len(req.Header.Values(name)) == 0 { + if req.Header.Get(name) == "" { req.Header.Add(name, value) } return tr.RoundTrip(req) @@ -57,7 +57,7 @@ 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 len(req.Header.Values(name)) > 0 { + if req.Header.Get(name) != "" { return tr.RoundTrip(req) } value, err := getValue(req) From 51c5595cad70de1316050773be3767b4c30f6dcd Mon Sep 17 00:00:00 2001 From: AliabbasMerchant Date: Wed, 2 Sep 2020 09:56:28 +0530 Subject: [PATCH 18/70] Interactive template selection test for PR create --- pkg/cmd/issue/create/create_test.go | 2 +- .../.github/ISSUE_TEMPLATE/bug_report.md | 0 .../.github/ISSUE_TEMPLATE/enhancement.md | 0 pkg/cmd/pr/create/create.go | 9 +- pkg/cmd/pr/create/create_test.go | 83 ++++++++++++++++++- .../.github/PULL_REQUEST_TEMPLATE/bug_fix.md | 8 ++ 6 files changed, 98 insertions(+), 4 deletions(-) rename pkg/cmd/issue/create/{ => fixtures}/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md (100%) rename pkg/cmd/issue/create/{ => fixtures}/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md (100%) create mode 100644 pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 9a3cd119f..fb46e6e1e 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -171,7 +171,7 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { }, }) - output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./repoWithNonLegacyIssueTemplates") + output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./fixtures/repoWithNonLegacyIssueTemplates") if err != nil { t.Errorf("error running command `issue create`: %v", err) } diff --git a/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md similarity index 100% rename from pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md rename to pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md diff --git a/pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md similarity index 100% rename from pkg/cmd/issue/create/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md rename to pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index c9e1fb6ab..156244ea5 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -28,6 +28,8 @@ type CreateOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) + RootDirOverride string + RepoOverride string Autofill bool @@ -246,8 +248,11 @@ func createRun(opts *CreateOptions) error { if !opts.WebMode && !opts.Autofill && 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") } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index e50a72c63..439b4dac1 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -31,6 +31,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 +64,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) @@ -239,6 +246,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, "Fixes a bug \nCloses 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() diff --git a/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md new file mode 100644 index 000000000..644463118 --- /dev/null +++ b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -0,0 +1,8 @@ +--- +name: "Bug fix" +about: Fix a bug + +--- + +Fixes a bug +Closes an issue \ No newline at end of file From 9e7279604d92416c3b3a79d6632cb5d19aa4998e Mon Sep 17 00:00:00 2001 From: AliabbasMerchant Date: Wed, 2 Sep 2020 10:03:56 +0530 Subject: [PATCH 19/70] Fix failing tests for Windows (due to line-ending issues) --- pkg/cmd/pr/create/create_test.go | 2 +- .../.github/PULL_REQUEST_TEMPLATE/bug_fix.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 439b4dac1..6b79551bf 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -314,7 +314,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") eq(t, reqBody.Variables.Input.Title, "my title") - eq(t, reqBody.Variables.Input.Body, "Fixes a bug \nCloses an issue") + eq(t, reqBody.Variables.Input.Body, "Fixes a bug and Closes an issue") eq(t, reqBody.Variables.Input.BaseRefName, "master") eq(t, reqBody.Variables.Input.HeadRefName, "feature") diff --git a/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md index 644463118..5e98de344 100644 --- a/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md +++ b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -4,5 +4,4 @@ about: Fix a bug --- -Fixes a bug -Closes an issue \ No newline at end of file +Fixes a bug and Closes an issue \ No newline at end of file From 2961c655fbff66711148d6379e0025c3694b321f Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 2 Sep 2020 11:33:39 -0500 Subject: [PATCH 20/70] review feedback --- api/queries_pr.go | 2 -- pkg/cmd/pr/checks/checks.go | 60 ++++++++++++++++++++------------ pkg/cmd/pr/checks/checks_test.go | 13 ++++++- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 6df1b91c9..db96e2faf 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -284,9 +284,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu state } ...on CheckRun { - name status - conclusion } } } diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index f42c3ec45..d3af8cbb0 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -89,19 +89,21 @@ func checksRun(opts *ChecksOptions) error { pending := 0 type output struct { - mark string - bucket string - name string - elapsed string - link string + 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 := "" + mark := "✓" + bucket := "pass" state := c.State + markColor := utils.Green if state == "" { if c.Status == "COMPLETED" { state = c.Conclusion @@ -111,15 +113,15 @@ func checksRun(opts *ChecksOptions) error { } switch state { case "SUCCESS", "NEUTRAL", "SKIPPED": - mark = utils.GreenCheck() passing++ - bucket = "pass" case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": - mark = utils.RedX() + mark = "X" + markColor = utils.Red failing++ bucket = "fail" case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE": - mark = utils.YellowDash() + mark = "-" + markColor = utils.Yellow pending++ bucket = "pending" default: @@ -146,28 +148,33 @@ func checksRun(opts *ChecksOptions) error { name = c.Context } - outputs = append(outputs, output{mark, bucket, name, elapsed, link}) + outputs = append(outputs, output{mark, bucket, name, elapsed, link, markColor}) } sort.Slice(outputs, func(i, j int) bool { - if outputs[i].bucket == outputs[j].bucket { - return outputs[i].name < outputs[j].name - } else { - if outputs[i].bucket == "fail" { - return true - } else if outputs[i].bucket == "pending" && outputs[j].bucket == "success" { - return true + 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 false + 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, nil) + 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) @@ -207,5 +214,14 @@ func checksRun(opts *ChecksOptions) error { fmt.Fprintln(opts.IO.Out) } - return tp.Render() + err = tp.Render() + if err != nil { + return err + } + + if failing+pending > 0 { + return cmdutil.SilentError + } + + return nil } diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 152f5aea9..43dbf8cda 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -68,6 +68,7 @@ func Test_checksRun(t *testing.T) { stubs func(*httpmock.Registry) wantOut string nontty bool + wantErr bool }{ { name: "no commits", @@ -96,11 +97,13 @@ func Test_checksRun(t *testing.T) { 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", @@ -111,6 +114,7 @@ func Test_checksRun(t *testing.T) { 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", @@ -142,12 +146,14 @@ func Test_checksRun(t *testing.T) { 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", @@ -160,6 +166,7 @@ func Test_checksRun(t *testing.T) { 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, }, } @@ -191,7 +198,11 @@ func Test_checksRun(t *testing.T) { } err := checksRun(opts) - assert.NoError(t, err) + if tt.wantErr { + assert.Equal(t, "SilentError", err.Error()) + } else { + assert.NoError(t, err) + } assert.Equal(t, tt.wantOut, stdout.String()) reg.Verify(t) From 02a057a709fcac549a06042d6f99d1729ffe3716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 2 Sep 2020 22:11:46 +0200 Subject: [PATCH 21/70] Try fixing Windows tests --- pkg/cmd/release/create/create_test.go | 2 +- pkg/cmd/release/download/download_test.go | 17 ++++++++++------- pkg/cmd/release/shared/upload.go | 23 ++++++++++++++++------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index f9fad4f81..8316d5baa 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -103,7 +103,7 @@ func Test_NewCmdCreate(t *testing.T) { }, { name: "notes from file", - args: "v1.2.3 -F" + tf.Name(), + args: fmt.Sprintf(`v1.2.3 -F '%s'`, tf.Name()), isTTY: true, want: CreateOptions{ TagName: "v1.2.3", diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 8a6956bb1..cd1a5469c 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "path/filepath" - "strings" "testing" "github.com/cli/cli/internal/ghrepo" @@ -129,9 +128,9 @@ func Test_downloadRun(t *testing.T) { wantStdout: ``, wantStderr: ``, wantFiles: []string{ - "./linux.tgz", - "./windows-32bit.zip", - "./windows-64bit.zip", + "linux.tgz", + "windows-32bit.zip", + "windows-64bit.zip", }, }, { @@ -146,8 +145,8 @@ func Test_downloadRun(t *testing.T) { wantStdout: ``, wantStderr: ``, wantFiles: []string{ - "./tmp/assets/windows-32bit.zip", - "./tmp/assets/windows-64bit.zip", + "tmp/assets/windows-32bit.zip", + "tmp/assets/windows-64bit.zip", }, }, { @@ -221,7 +220,11 @@ 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() { - files = append(files, "."+strings.TrimPrefix(p, dir)) + rp, err := filepath.Rel(dir, p) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rp)) } return err }) diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index bdb0c837a..ef5ff4890 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -56,14 +56,13 @@ func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) { } func typeForFilename(fn string) string { - fn = strings.ToLower(fn) - if strings.HasSuffix(fn, ".tar.gz") { - return "application/x-gtar" - } - - ext := path.Ext(fn) + ext := fileExt(fn) switch ext { - case ".tgz": + case "zip": + return "application/zip" + case "js": + return "application/javascript" + case ".tgz", ".tar.gz": return "application/x-gtar" case ".bz2": return "application/x-bzip2" @@ -71,6 +70,8 @@ func typeForFilename(fn string) string { return "application/x-apple-diskimage" case ".rpm": return "application/x-rpm" + case ".deb": + return "application/x-debian-package" } t := mime.TypeByExtension(ext) @@ -80,6 +81,14 @@ func typeForFilename(fn string) string { 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") From 151a7340a67cc570caf6fca0ff2ddd0c29813d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Sep 2020 11:44:54 +0200 Subject: [PATCH 22/70] Fix `.zip` and `.js` overrides for Windows --- pkg/cmd/release/shared/upload.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index ef5ff4890..f98f46b92 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -58,9 +58,9 @@ func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) { func typeForFilename(fn string) string { ext := fileExt(fn) switch ext { - case "zip": + case ".zip": return "application/zip" - case "js": + case ".js": return "application/javascript" case ".tgz", ".tar.gz": return "application/x-gtar" From d4b45c68e2c770e4388bd6112dcb666d666d2e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Sep 2020 15:34:10 +0200 Subject: [PATCH 23/70] Allow downloading assets from the latest release --- pkg/cmd/release/download/download.go | 45 ++++++++++++++++++----- pkg/cmd/release/download/download_test.go | 17 +++++++-- pkg/cmd/release/view/view.go | 9 ++++- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 0521fe12e..2810d68e3 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -7,6 +7,7 @@ import ( "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" @@ -35,16 +36,32 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr } cmd := &cobra.Command{ - Use: "download []", + Use: "download []", Short: "Download release assets", - Args: cobra.MinimumNArgs(1), + 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' + `), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.TagName = args[0] - if len(args) > 1 { - opts.FilePattern = args[1] + if len(args) == 0 { + if opts.FilePattern == "" { + return &cmdutil.FlagError{Err: errors.New("the '--pattern' flag is required when downloading the latest release")} + } + } else { + opts.TagName = args[0] } opts.Concurrency = 5 @@ -56,7 +73,8 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr }, } - cmd.Flags().StringVarP(&opts.Destination, "dir", "C", ".", "The directory to download files into") + cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The directory to download files into") + cmd.Flags().StringVarP(&opts.FilePattern, "pattern", "p", "", "Download only assets that match the glob pattern") return cmd } @@ -72,9 +90,18 @@ func downloadRun(opts *DownloadOptions) error { return err } - release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) - 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 diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index cd1a5469c..7c16e7fed 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -38,7 +38,7 @@ func Test_NewCmdDownload(t *testing.T) { }, { name: "version and file pattern", - args: "v1.2.3 *.tgz", + args: "v1.2.3 -p *.tgz", isTTY: true, want: DownloadOptions{ TagName: "v1.2.3", @@ -49,7 +49,7 @@ func Test_NewCmdDownload(t *testing.T) { }, { name: "version and destination", - args: "v1.2.3 -C tmp/assets", + args: "v1.2.3 -D tmp/assets", isTTY: true, want: DownloadOptions{ TagName: "v1.2.3", @@ -58,11 +58,22 @@ func Test_NewCmdDownload(t *testing.T) { Concurrency: 5, }, }, + { + name: "download latest", + args: "-p *", + isTTY: true, + want: DownloadOptions{ + TagName: "", + FilePattern: "*", + Destination: ".", + Concurrency: 5, + }, + }, { name: "no arguments", args: "", isTTY: true, - wantErr: "requires at least 1 arg(s), only received 0", + wantErr: "the '--pattern' flag is required when downloading the latest release", }, } for _, tt := range tests { diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 6de42abc1..63adf9901 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -7,6 +7,7 @@ import ( "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" @@ -32,7 +33,13 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "view []", Short: "View information about a release", - Args: cobra.MaximumNArgs(1), + 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 From 1ad9b0b14e97684def27645fe4b08b179efa41ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Sep 2020 15:35:07 +0200 Subject: [PATCH 24/70] Have `FindDraftRelease` be truer to its name --- pkg/cmd/release/shared/fetch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 7e233fb6b..4cc31ff48 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -113,7 +113,7 @@ func FetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*Re return &release, nil } -// FindDraftRelease interates over all releases in a repository until it finds one that matches tagName. +// 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 @@ -148,7 +148,7 @@ func FindDraftRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagNam } for _, r := range releases { - if r.TagName == tagName { + if r.IsDraft && r.TagName == tagName { return &r, nil } } From a470d49c6447336832b0cffd53ee22f10d1fb32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 3 Sep 2020 16:06:28 +0200 Subject: [PATCH 25/70] Fix reporting release URL when creating with assets --- pkg/cmd/release/create/create.go | 3 +- pkg/cmd/release/create/create_test.go | 50 +++++++++++++++++++++++++++ pkg/cmd/release/create/http.go | 19 ++++++---- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 4367b94ae..720eceade 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -279,10 +279,11 @@ func createRun(opts *CreateOptions) error { } if !opts.Draft { - err := publishRelease(httpClient, newRelease.APIURL) + rel, err := publishRelease(httpClient, newRelease.APIURL) if err != nil { return err } + newRelease = rel } } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 8316d5baa..806801ec2 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "os" @@ -290,6 +291,36 @@ func Test_createRun(t *testing.T) { 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) { @@ -300,8 +331,14 @@ func Test_createRun(t *testing.T) { 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) { @@ -326,6 +363,19 @@ func Test_createRun(t *testing.T) { 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()) }) diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 684772c12..b8c838579 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "io/ioutil" "net/http" @@ -50,24 +49,30 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st return &newRelease, err } -func publishRelease(httpClient *http.Client, releaseURL string) error { +func publishRelease(httpClient *http.Client, releaseURL string) (*shared.Release, error) { req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBufferString(`{"draft":false}`)) if err != nil { - return err + return nil, err } req.Header.Add("Content-Type", "application/json") resp, err := httpClient.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) + return nil, api.HandleHTTPError(resp) } - _, err = io.Copy(ioutil.Discard, resp.Body) - return err + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var release shared.Release + err = json.Unmarshal(b, &release) + return &release, err } From cab0e0f43da25800f34e60c1baec076cba979bd9 Mon Sep 17 00:00:00 2001 From: Giacomo Alberini Date: Fri, 4 Sep 2020 23:07:51 +0200 Subject: [PATCH 26/70] Add pr Title and number to commitHeadline in merge request --- api/queries_pr.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 18be5e1fa..37a1c27df 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -981,10 +981,12 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m } `graphql:"mergePullRequest(input: $input)"` } + commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)) variables := map[string]interface{}{ "input": githubv4.MergePullRequestInput{ - PullRequestID: pr.ID, - MergeMethod: &mergeMethod, + PullRequestID: pr.ID, + MergeMethod: &mergeMethod, + CommitHeadline: &commitHeadline, }, } From d77a8c2e61d8cca4e7d412b8ed3277360907e736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 7 Sep 2020 22:27:27 +0200 Subject: [PATCH 27/70] Scope `pr merge` commit headline to only squash method --- api/queries_pr.go | 17 +++++++++++------ pkg/cmd/pr/merge/merge_test.go | 9 +++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 37a1c27df..5ebaaa613 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -981,13 +981,18 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m } `graphql:"mergePullRequest(input: $input)"` } - commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)) + 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, - CommitHeadline: &commitHeadline, - }, + "input": input, } gql := graphQLClient(client.http, repo.RepoHost()) diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 2b5f89e7b..6abea963c 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -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"), From 527a1e466f61f4778bffd79ab3fad8c42c7753f0 Mon Sep 17 00:00:00 2001 From: Lee Reilly Date: Tue, 8 Sep 2020 14:07:33 -0700 Subject: [PATCH 28/70] Add auth command and flag usage to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0981951a4..e3aa83aa4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ We'd love to hear your feedback about `gh`. If you spot bugs or have features th - `gh pr [status, list, view, checkout, create]` - `gh issue [status, list, view, create]` - `gh repo [view, create, clone, fork]` +- `gh auth [login, logout, refresh, status]` - `gh config [get, set]` - `gh help` From 338f37d6aafe8311d7359f2afed04fe52154700b Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 8 Sep 2020 16:11:15 -0500 Subject: [PATCH 29/70] support extant ubuntu LTSs --- script/distributions | 38 +++++++++++++++++++++- script/{override.focal => override.ubuntu} | 0 2 files changed, 37 insertions(+), 1 deletion(-) rename script/{override.focal => override.ubuntu} (100%) diff --git a/script/distributions b/script/distributions index 67536e4c2..b6f697dc1 100644 --- a/script/distributions +++ b/script/distributions @@ -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 focal repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: bionic +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu focal repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: trusty +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu focal repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: xenial +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu focal repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu diff --git a/script/override.focal b/script/override.ubuntu similarity index 100% rename from script/override.focal rename to script/override.ubuntu From e59431eeef36aadadb2bd68bc99d1e50669dad87 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 8 Sep 2020 16:26:25 -0500 Subject: [PATCH 30/70] more ubuntus --- .github/workflows/releases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index a9d13d200..fe7d07b37 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -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 From c4227455e3ca5aaf288442331ac36c7a6e89f565 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Tue, 1 Sep 2020 12:40:36 -0400 Subject: [PATCH 31/70] Prepend PR body defaults to the selected template (if any) See #1570 --- pkg/cmd/pr/shared/title_body_survey.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/shared/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go index 3afeba803..70308e310 100644 --- a/pkg/cmd/pr/shared/title_body_survey.go +++ b/pkg/cmd/pr/shared/title_body_survey.go @@ -154,18 +154,24 @@ 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 != "" { + issueState.Body += "\n\n" + } + issueState.Body += templateContents } } From a33124414ce972122fc1bc7ddae1c8910c446f64 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Tue, 1 Sep 2020 21:29:22 -0400 Subject: [PATCH 32/70] Add a test to verify the behavior of prepending commit body to message --- pkg/cmd/pr/create/create_test.go | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 6b79551bf..d6bfb1f76 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "io/ioutil" "net/http" + "os" + "path" "reflect" "strings" "testing" @@ -851,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) From 41543b564d1d8a4a9f80fdb8ce9429a38553a960 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Tue, 1 Sep 2020 21:37:42 -0400 Subject: [PATCH 33/70] "Check" the error value of ioutil.WriteFile We could, alternatively, use t.Fatal... this matches the MkdirAll invocation from github_template_test, however... --- pkg/cmd/pr/create/create_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index d6bfb1f76..b947b22a5 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -889,7 +889,7 @@ func TestPRCreate_survey_defaults_monocommit_template(t *testing.T) { 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) + _ = ioutil.WriteFile(templateFp, []byte("... turned to a dead channel"), 0700) cs.Stub("") // git config --get-regexp (determineTrackingBranch) cs.Stub("") // git show-ref --verify (determineTrackingBranch) From c7e702203bef0db6578cd306067e5f829204347b Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Tue, 8 Sep 2020 20:25:52 -0400 Subject: [PATCH 34/70] Update template test to reflect inclusion of "default" text Also, avoid excessive amounts of newlines separating the two --- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/shared/title_body_survey.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index b947b22a5..f3426702f 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -316,7 +316,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") eq(t, reqBody.Variables.Input.Title, "my title") - eq(t, reqBody.Variables.Input.Body, "Fixes a bug and Closes an issue") + 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") diff --git a/pkg/cmd/pr/shared/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go index 70308e310..83a50e0ff 100644 --- a/pkg/cmd/pr/shared/title_body_survey.go +++ b/pkg/cmd/pr/shared/title_body_survey.go @@ -2,6 +2,7 @@ package shared import ( "fmt" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/api" @@ -169,6 +170,8 @@ func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState * 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 From 2d87072c79494b1b1e5b679ad0b4cb72da87ec75 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 9 Sep 2020 09:33:49 +0200 Subject: [PATCH 35/70] Fix 32 bit arch name --- docs/install_linux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 93e9f3efd..30052d808 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -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. From 0c16a6b0b5f5b3152473a4d723632fde48734ca8 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 09:50:10 -0500 Subject: [PATCH 36/70] fix butchered query --- api/queries_pr.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index e0bc61cd9..d2499217e 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -280,10 +280,10 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu contexts(last: 100) { nodes { ...on StatusContext { - context state } ...on CheckRun { + conclusion status } } From 9db9370a3e93ce8df244bf00141ce688e95b7caf Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 09:53:34 -0500 Subject: [PATCH 37/70] fix descriptions --- script/distributions | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/distributions b/script/distributions index b6f697dc1..1f8ec6ddb 100644 --- a/script/distributions +++ b/script/distributions @@ -20,7 +20,7 @@ Label: gh Codename: precise Architectures: i386 amd64 arm64 Components: main -Description: The GitHub CLI - ubuntu focal repo +Description: The GitHub CLI - ubuntu precise repo SignWith: C99B11DEB97541F0 DebOverride: override.ubuntu @@ -29,7 +29,7 @@ Label: gh Codename: bionic Architectures: i386 amd64 arm64 Components: main -Description: The GitHub CLI - ubuntu focal repo +Description: The GitHub CLI - ubuntu bionic repo SignWith: C99B11DEB97541F0 DebOverride: override.ubuntu @@ -38,7 +38,7 @@ Label: gh Codename: trusty Architectures: i386 amd64 arm64 Components: main -Description: The GitHub CLI - ubuntu focal repo +Description: The GitHub CLI - ubuntu trusty repo SignWith: C99B11DEB97541F0 DebOverride: override.ubuntu @@ -47,6 +47,6 @@ Label: gh Codename: xenial Architectures: i386 amd64 arm64 Components: main -Description: The GitHub CLI - ubuntu focal repo +Description: The GitHub CLI - ubuntu xenial repo SignWith: C99B11DEB97541F0 DebOverride: override.ubuntu From 3d58fa785fc7f35aa08267db5b5080aaa583953a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:01:29 +0200 Subject: [PATCH 38/70] Add the `release view --web` flag --- pkg/cmd/release/view/view.go | 10 ++++++++++ pkg/cmd/release/view/view_test.go | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 63adf9901..0e5710ce6 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -22,6 +22,7 @@ type ViewOptions struct { BaseRepo func() (ghrepo.Interface, error) TagName string + WebMode bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -55,6 +56,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman }, } + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the release in the browser") + return cmd } @@ -83,6 +86,13 @@ func viewRun(opts *ViewOptions) error { } } + 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 diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index df6ee0860..dba25b680 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -32,6 +32,7 @@ func Test_NewCmdView(t *testing.T) { isTTY: true, want: ViewOptions{ TagName: "v1.2.3", + WebMode: false, }, }, { @@ -40,6 +41,16 @@ func Test_NewCmdView(t *testing.T) { isTTY: true, want: ViewOptions{ TagName: "", + WebMode: false, + }, + }, + { + name: "web mode", + args: "-w", + isTTY: true, + want: ViewOptions{ + TagName: "", + WebMode: true, }, }, } @@ -78,6 +89,7 @@ func Test_NewCmdView(t *testing.T) { } assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.WebMode, opts.WebMode) }) } } From fda9659e4a515cb3df200bd6412e75bc599d141a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:02:02 +0200 Subject: [PATCH 39/70] Consistently print browsed URL across commands --- pkg/cmd/issue/view/view.go | 4 +++- pkg/cmd/pr/view/view.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 6d2b0eeaa..aeec309a0 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -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() { diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index b653e5ffe..c42048043 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -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) } From 202d11146d9859c02b5b74a205c6e8fdaada9b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:09:49 +0200 Subject: [PATCH 40/70] Add success message to `release upload` --- pkg/cmd/release/upload/upload.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index eb2d62151..f65d05348 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -104,5 +105,12 @@ func uploadRun(opts *UploadOptions) error { 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 } From 7614ffee304a20329e96e355d932d86e715b00c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:36:25 +0200 Subject: [PATCH 41/70] Add docs to `release create/upload` --- pkg/cmd/release/create/create.go | 16 +++++++++++++++- pkg/cmd/release/upload/upload.go | 9 ++++++++- pkg/cmd/release/view/view.go | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 720eceade..6140d3b04 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -10,6 +10,7 @@ import ( "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" @@ -62,7 +63,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "create [...]", Short: "Create a new release", - Args: cobra.MinimumNArgs(1), + 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 diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index f65d05348..79e67f7ea 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -5,6 +5,7 @@ import ( "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" @@ -35,7 +36,13 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "upload ...", Short: "Upload assets to a release", - Args: cobra.MinimumNArgs(2), + 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 diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 0e5710ce6..8eb8ce03c 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -35,7 +35,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman Use: "view []", Short: "View information about a release", Long: heredoc.Doc(` - View information about a GitHub release. + View information about a GitHub Release. Without an explicit tag name argument, the latest release in the project is shown. From 1ce61d4b80d8d7f3578f51fd376ff1195c0fa3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:36:49 +0200 Subject: [PATCH 42/70] Allow specifying multiple patterns in `release download` --- pkg/cmd/release/download/download.go | 28 +++++++---- pkg/cmd/release/download/download_test.go | 61 +++++++++++++---------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 2810d68e3..d8c28e289 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -21,9 +21,9 @@ type DownloadOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - TagName string - FilePattern string - Destination string + TagName string + FilePatterns []string + Destination string // maximum number of simultaneous downloads Concurrency int @@ -50,6 +50,9 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr # 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 { @@ -57,7 +60,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr opts.BaseRepo = f.BaseRepo if len(args) == 0 { - if opts.FilePattern == "" { + if len(opts.FilePatterns) == 0 { return &cmdutil.FlagError{Err: errors.New("the '--pattern' flag is required when downloading the latest release")} } } else { @@ -74,7 +77,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr } cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The directory to download files into") - cmd.Flags().StringVarP(&opts.FilePattern, "pattern", "p", "", "Download only assets that match the glob pattern") + cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern") return cmd } @@ -106,10 +109,8 @@ func downloadRun(opts *DownloadOptions) error { var toDownload []shared.ReleaseAsset for _, a := range release.Assets { - if opts.FilePattern != "" { - if isMatch, err := filepath.Match(opts.FilePattern, a.Name); err != nil || !isMatch { - continue - } + if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) { + continue } toDownload = append(toDownload, a) } @@ -134,6 +135,15 @@ func downloadRun(opts *DownloadOptions) error { 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") diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 7c16e7fed..9fa8677c6 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -30,10 +30,10 @@ func Test_NewCmdDownload(t *testing.T) { args: "v1.2.3", isTTY: true, want: DownloadOptions{ - TagName: "v1.2.3", - FilePattern: "", - Destination: ".", - Concurrency: 5, + TagName: "v1.2.3", + FilePatterns: []string(nil), + Destination: ".", + Concurrency: 5, }, }, { @@ -41,10 +41,21 @@ func Test_NewCmdDownload(t *testing.T) { args: "v1.2.3 -p *.tgz", isTTY: true, want: DownloadOptions{ - TagName: "v1.2.3", - FilePattern: "*.tgz", - Destination: ".", - Concurrency: 5, + 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, }, }, { @@ -52,10 +63,10 @@ func Test_NewCmdDownload(t *testing.T) { args: "v1.2.3 -D tmp/assets", isTTY: true, want: DownloadOptions{ - TagName: "v1.2.3", - FilePattern: "", - Destination: "tmp/assets", - Concurrency: 5, + TagName: "v1.2.3", + FilePatterns: []string(nil), + Destination: "tmp/assets", + Concurrency: 5, }, }, { @@ -63,10 +74,10 @@ func Test_NewCmdDownload(t *testing.T) { args: "-p *", isTTY: true, want: DownloadOptions{ - TagName: "", - FilePattern: "*", - Destination: ".", - Concurrency: 5, + TagName: "", + FilePatterns: []string{"*"}, + Destination: ".", + Concurrency: 5, }, }, { @@ -111,7 +122,7 @@ func Test_NewCmdDownload(t *testing.T) { } assert.Equal(t, tt.want.TagName, opts.TagName) - assert.Equal(t, tt.want.FilePattern, opts.FilePattern) + assert.Equal(t, tt.want.FilePatterns, opts.FilePatterns) assert.Equal(t, tt.want.Destination, opts.Destination) assert.Equal(t, tt.want.Concurrency, opts.Concurrency) }) @@ -148,10 +159,10 @@ func Test_downloadRun(t *testing.T) { name: "download assets matching pattern into destination directory", isTTY: true, opts: DownloadOptions{ - TagName: "v1.2.3", - FilePattern: "windows-*.zip", - Destination: "tmp/assets", - Concurrency: 2, + TagName: "v1.2.3", + FilePatterns: []string{"windows-*.zip"}, + Destination: "tmp/assets", + Concurrency: 2, }, wantStdout: ``, wantStderr: ``, @@ -164,10 +175,10 @@ func Test_downloadRun(t *testing.T) { name: "no match for pattern", isTTY: true, opts: DownloadOptions{ - TagName: "v1.2.3", - FilePattern: "linux*.zip", - Destination: ".", - Concurrency: 2, + TagName: "v1.2.3", + FilePatterns: []string{"linux*.zip"}, + Destination: ".", + Concurrency: 2, }, wantStdout: ``, wantStderr: ``, From 9040f6adc62585a75986367e7ce8f0d9f945dc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:40:04 +0200 Subject: [PATCH 43/70] Fix `view` tests --- pkg/cmd/issue/view/view_test.go | 4 ++-- pkg/cmd/pr/view/view_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index e27505f92..0567c87ff 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -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") diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index fa9e3941a..6dc3561f7 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -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") From 50d581318245bd3a0030d186e77e26080e6fb134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:45:55 +0200 Subject: [PATCH 44/70] Indicate that title is optional in `release create` --- pkg/cmd/release/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 6140d3b04..5534821c1 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -174,7 +174,7 @@ func createRun(opts *CreateOptions) error { { Name: "name", Prompt: &survey.Input{ - Message: "Title", + Message: "Title (optional)", Default: opts.Name, }, }, From 29172e13912a6c74182533370a92dfa2ca1792e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:49:19 +0200 Subject: [PATCH 45/70] bump AlecAivazis/survey to v2.1.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5f429ac99..f33d704f3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ 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 diff --git a/go.sum b/go.sum index 0fb6e78da..56a4547cd 100644 --- a/go.sum +++ b/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= From 8dfc8cc1728a3f9571adb5acf370325468854035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:49:57 +0200 Subject: [PATCH 46/70] bump henvic/httpretty to v0.0.6 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f33d704f3..b58fd6f77 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/google/go-cmp v0.4.1 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/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-isatty v0.0.12 diff --git a/go.sum b/go.sum index 56a4547cd..b47f59b11 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t 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/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= From 03667e44d00f4f90bb32fbc952660ac493a61fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:50:21 +0200 Subject: [PATCH 47/70] bump stretchr/testify to v1.6.1 --- go.mod | 2 +- go.sum | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b58fd6f77..744ce71ec 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( 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 + github.com/stretchr/testify v1.6.1 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/text v0.3.3 gopkg.in/yaml.v2 v2.2.8 // indirect diff --git a/go.sum b/go.sum index b47f59b11..78f52ea0b 100644 --- a/go.sum +++ b/go.sum @@ -176,8 +176,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= @@ -253,6 +253,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-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From 03e4284798fef4a569d5990ce466c78b83067335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:50:46 +0200 Subject: [PATCH 48/70] bump mattn/go-colorable to v0.1.7 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 744ce71ec..73edf113f 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/hashicorp/go-version v1.2.0 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/mitchellh/go-homedir v1.1.0 diff --git a/go.sum b/go.sum index 78f52ea0b..6409f0155 100644 --- a/go.sum +++ b/go.sum @@ -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= From b52b669f6a3e84aca60112a357cc4e914d1637f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:51:02 +0200 Subject: [PATCH 49/70] bump google/go-cmp to v0.5.2 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 73edf113f..aadab95de 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( 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.6 diff --git a/go.sum b/go.sum index 6409f0155..46e678969 100644 --- a/go.sum +++ b/go.sum @@ -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= From 2eebae1e9950eb7d44f1055d077e109f4bec5e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:51:14 +0200 Subject: [PATCH 50/70] bump hashicorp/go-version to v1.2.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index aadab95de..334c95de7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 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/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.7 diff --git a/go.sum b/go.sum index 46e678969..c0e7eb809 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ 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.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= From de1e9e848b0b26c67a6db15952d6b2f8f9cc1e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:51:50 +0200 Subject: [PATCH 51/70] bump mgutz/ansi --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 334c95de7..5c5a012b2 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 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/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/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f diff --git a/go.sum b/go.sum index c0e7eb809..991551832 100644 --- a/go.sum +++ b/go.sum @@ -119,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= From c00fa2d42b8239a112b3fa2b0affbbbcc452258f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:52:43 +0200 Subject: [PATCH 52/70] bump shurcooL/githubv4 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5c5a012b2..86c59c418 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/mattn/go-isatty v0.0.12 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/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 diff --git a/go.sum b/go.sum index 991551832..6a4750734 100644 --- a/go.sum +++ b/go.sum @@ -156,8 +156,8 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0 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= From 640450de6f9e7a215c38d04535253d4146fc605e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:53:10 +0200 Subject: [PATCH 53/70] bump golang.org/x/crypto --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 86c59c418..ea2fea539 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.6.1 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 + golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a golang.org/x/text v0.3.3 gopkg.in/yaml.v2 v2.2.8 // indirect gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 diff --git a/go.sum b/go.sum index 6a4750734..f028373d1 100644 --- a/go.sum +++ b/go.sum @@ -198,6 +198,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= From 8289dcb55e26ed5ff464800d1813f02760986b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Sep 2020 17:53:26 +0200 Subject: [PATCH 54/70] bump yaml.v3 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ea2fea539..42a005291 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a golang.org/x/text v0.3.3 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 diff --git a/go.sum b/go.sum index f028373d1..107d9a245 100644 --- a/go.sum +++ b/go.sum @@ -260,6 +260,6 @@ 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-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -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-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= From 33f7d770f30b21439e59791d3bc626923d67744a Mon Sep 17 00:00:00 2001 From: Dhruvil Dave <43331416+dhruvildave@users.noreply.github.com> Date: Thu, 10 Sep 2020 11:55:36 +0530 Subject: [PATCH 55/70] Add opensuse zypper installation steps --- docs/install_linux.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 30052d808..4a21c0094 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -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 From e50833cae0fe90aedb30c70a9acfcd7df8fdbd07 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 10 Sep 2020 09:21:57 +0200 Subject: [PATCH 56/70] Do not html escape repo view output --- pkg/cmd/repo/view/view.go | 2 +- pkg/cmd/repo/view/view_test.go | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index adcf384a4..7896d1d71 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -2,9 +2,9 @@ package view import ( "fmt" - "html/template" "net/http" "strings" + "text/template" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go index 7dba0f6df..626ff62ed 100644 --- a/pkg/cmd/repo/view/view_test.go +++ b/pkg/cmd/repo/view/view_test.go @@ -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) + }) + } +} From 5119d211374f60b3f9ad591447a397d2b3838349 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 12:01:47 -0500 Subject: [PATCH 57/70] support Config in IOStreams --- pkg/cmd/factory/default.go | 4 ++-- pkg/iostreams/iostreams.go | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index ae8622b2a..7bc4d12a5 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -14,8 +14,6 @@ import ( ) func New(appVersion string) *cmdutil.Factory { - io := iostreams.System() - var cachedConfig config.Config var configError error configFunc := func() (config.Config, error) { @@ -31,6 +29,8 @@ func New(appVersion string) *cmdutil.Factory { return cachedConfig, configError } + io := iostreams.System(configFunc) + rr := &remoteResolver{ readRemotes: git.Remotes, getConfig: configFunc, diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index a5daacc84..00fa512c8 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -12,6 +12,7 @@ import ( "time" "github.com/briandowns/spinner" + "github.com/cli/cli/internal/config" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" @@ -22,6 +23,9 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer + // Used to understand user prefences around interactivity + Config func() (config.Config, error) + // the original (non-colorable) output stream originalOut io.Writer colorEnabled bool @@ -135,7 +139,7 @@ func (s *IOStreams) ColorScheme() *ColorScheme { return NewColorScheme(s.ColorEnabled()) } -func System() *IOStreams { +func System(configFunc func() (config.Config, error)) *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) @@ -145,6 +149,7 @@ func System() *IOStreams { Out: colorable.NewColorable(os.Stdout), ErrOut: colorable.NewColorable(os.Stderr), colorEnabled: os.Getenv("NO_COLOR") == "" && stdoutIsTTY, + Config: configFunc, } if stdoutIsTTY && stderrIsTTY { @@ -165,6 +170,9 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { In: ioutil.NopCloser(in), Out: out, ErrOut: errOut, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, }, in, out, errOut } From ecd1b34b398800c7da56a2c91eba9b476905239b Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 12:02:12 -0500 Subject: [PATCH 58/70] Add prompt config setting and support in CanPrompt --- internal/config/config_type.go | 17 ++++++++++++++++- pkg/iostreams/iostreams.go | 11 +++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 8bb20df43..584bedabb 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -10,7 +10,11 @@ import ( "gopkg.in/yaml.v3" ) -const defaultGitProtocol = "https" +const ( + defaultGitProtocol = "https" + NeverPrompt = "never" + AutoPrompt = "auto" +) // 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: auto, never", + Kind: yaml.ScalarNode, + Value: "prompt", + }, + { + Kind: yaml.ScalarNode, + Value: AutoPrompt, + }, { 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 AutoPrompt default: return "" } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 00fa512c8..b143b8c70 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -91,6 +91,17 @@ func (s *IOStreams) IsStderrTTY() bool { } func (s *IOStreams) CanPrompt() bool { + cfg, err := s.Config() + if err == nil { + // TODO prompt is a global setting. Is this inconsistency ok? There's not a super strong case to + // set prompting by host, but it means we need to be clear about what can and can't be set per + // host. + prompt, _ := cfg.Get("", "prompt") + if prompt == config.NeverPrompt { + return false + } + } + return s.IsStdinTTY() && s.IsStdoutTTY() } From 15bfb5447b38da8bcd27914a48ab2ab3e34eb4b1 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 14:02:23 -0500 Subject: [PATCH 59/70] split auth flow code into new internal package --- Makefile | 4 ++-- .../{config/config_setup.go => authflow/flow.go} | 14 +++++--------- .../config_success.go => authflow/success.go} | 2 +- pkg/cmd/auth/login/login.go | 3 ++- pkg/cmd/auth/refresh/refresh.go | 3 ++- 5 files changed, 12 insertions(+), 14 deletions(-) rename internal/{config/config_setup.go => authflow/flow.go} (85%) rename internal/{config/config_success.go => authflow/success.go} (98%) diff --git a/Makefile b/Makefile index decf5125c..51af20952 100644 --- a/Makefile +++ b/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) diff --git a/internal/config/config_setup.go b/internal/authflow/flow.go similarity index 85% rename from internal/config/config_setup.go rename to internal/authflow/flow.go index 649e105b7..1c804931f 100644 --- a/internal/config/config_setup.go +++ b/internal/authflow/flow.go @@ -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) diff --git a/internal/config/config_success.go b/internal/authflow/success.go similarity index 98% rename from internal/config/config_success.go rename to internal/authflow/success.go index c7a681bac..a546e58d7 100644 --- a/internal/config/config_success.go +++ b/internal/authflow/success.go @@ -1,4 +1,4 @@ -package config +package authflow const oauthSuccessPage = ` diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index eb58484ec..c0a24c5c5 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -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" @@ -202,7 +203,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) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 757903bdb..7fe5a8c5f 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -5,6 +5,7 @@ import ( "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 +27,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 }, } From 79b5ca2cd69cf6db43a4aca170024f5485f39333 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 15:19:14 -0500 Subject: [PATCH 60/70] update default config test values --- internal/config/config_file_test.go | 2 +- internal/config/config_type_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 2d7fd4b73..112221021 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -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: auto, never\nprompt: auto\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" expectedHosts := `github.com: user: keiyuri oauth_token: "123456" diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index df33effa3..9a57e0c4a 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -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: auto, never\nprompt: auto\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: auto, never\nprompt: auto\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()) From fcc0e75a50b37d42716856645ead9fdc9a6dea40 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Sep 2020 11:04:39 -0500 Subject: [PATCH 61/70] do not pass entire config to iostreams --- cmd/gh/main.go | 18 ++++++++++++------ pkg/cmd/factory/default.go | 4 ++-- pkg/iostreams/iostreams.go | 27 +++++++++------------------ 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 44357ffd6..d4a1fe293 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -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.NeverPrompt { + 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) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 7bc4d12a5..ae8622b2a 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -14,6 +14,8 @@ import ( ) func New(appVersion string) *cmdutil.Factory { + io := iostreams.System() + var cachedConfig config.Config var configError error configFunc := func() (config.Config, error) { @@ -29,8 +31,6 @@ func New(appVersion string) *cmdutil.Factory { return cachedConfig, configError } - io := iostreams.System(configFunc) - rr := &remoteResolver{ readRemotes: git.Remotes, getConfig: configFunc, diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index b143b8c70..9a0ce4ff8 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -12,7 +12,6 @@ import ( "time" "github.com/briandowns/spinner" - "github.com/cli/cli/internal/config" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" @@ -23,9 +22,6 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer - // Used to understand user prefences around interactivity - Config func() (config.Config, error) - // the original (non-colorable) output stream originalOut io.Writer colorEnabled bool @@ -39,6 +35,8 @@ type IOStreams struct { stdoutIsTTY bool stderrTTYOverride bool stderrIsTTY bool + + neverPrompt bool } func (s *IOStreams) ColorEnabled() bool { @@ -91,20 +89,17 @@ func (s *IOStreams) IsStderrTTY() bool { } func (s *IOStreams) CanPrompt() bool { - cfg, err := s.Config() - if err == nil { - // TODO prompt is a global setting. Is this inconsistency ok? There's not a super strong case to - // set prompting by host, but it means we need to be clear about what can and can't be set per - // host. - prompt, _ := cfg.Get("", "prompt") - if prompt == config.NeverPrompt { - return false - } + 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 @@ -150,7 +145,7 @@ func (s *IOStreams) ColorScheme() *ColorScheme { return NewColorScheme(s.ColorEnabled()) } -func System(configFunc func() (config.Config, error)) *IOStreams { +func System() *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) @@ -160,7 +155,6 @@ func System(configFunc func() (config.Config, error)) *IOStreams { Out: colorable.NewColorable(os.Stdout), ErrOut: colorable.NewColorable(os.Stderr), colorEnabled: os.Getenv("NO_COLOR") == "" && stdoutIsTTY, - Config: configFunc, } if stdoutIsTTY && stderrIsTTY { @@ -181,9 +175,6 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { In: ioutil.NopCloser(in), Out: out, ErrOut: errOut, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil - }, }, in, out, errOut } From c0fc31f7d551350632a2c260331de8353e73deeb Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 9 Sep 2020 15:55:21 -0500 Subject: [PATCH 62/70] use CanPrompt in commands --- pkg/cmd/auth/login/login.go | 6 ++++ pkg/cmd/auth/login/login_test.go | 11 ++----- pkg/cmd/auth/logout/logout.go | 16 +++++----- pkg/cmd/auth/logout/logout_test.go | 37 ++++++++++++++++------- pkg/cmd/auth/refresh/refresh.go | 18 ++++++++---- pkg/cmd/auth/refresh/refresh_test.go | 44 +++++++++++++++++++--------- pkg/cmd/issue/create/create.go | 4 +++ pkg/cmd/pr/create/create.go | 5 ++-- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/merge/merge.go | 4 +-- pkg/cmd/pr/merge/merge_test.go | 2 +- pkg/cmd/pr/review/review.go | 4 +-- pkg/cmd/pr/review/review_test.go | 2 +- pkg/cmd/repo/create/create.go | 11 ++++++- pkg/cmd/repo/create/create_test.go | 18 +++++++----- pkg/cmd/repo/fork/fork.go | 2 +- 16 files changed, 119 insertions(+), 67 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index c0a24c5c5..42cd69f51 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -72,6 +72,12 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm if opts.Hostname == "" { opts.Hostname = ghinstance.Default() } + } else { + if !opts.IO.CanPrompt() { + // TODO this error message kind of sucks since the prompting might be disabled from being + // in a nontty or from the config setting. Is it enough info? + return &cmdutil.FlagError{Err: errors.New("--with-token required when prompts disabled")} + } } if cmd.Flags().Changed("hostname") { diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index d90a0a2d6..5d0ba1942 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -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) diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index c5810f5b8..4a39f2666 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -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 prompts disabled")} + } + 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) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 724705776..a9d14cc91 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -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{ diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 7fe5a8c5f..939b6f5a6 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -1,6 +1,7 @@ package refresh import ( + "errors" "fmt" "github.com/AlecAivazis/survey/v2" @@ -49,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 prompts disabled")} + } + if runF != nil { return runF(opts) } @@ -64,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 diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index faacb0aa7..cba3c107a 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -14,34 +14,51 @@ 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 }{ { - 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: "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 +72,8 @@ func Test_NewCmdRefresh(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) @@ -73,11 +92,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 +118,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{}, diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 4190fba3f..b91d5786c 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -157,6 +157,10 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("must provide --title and --body when not attached to a terminal") } + if interactive && !opts.IO.CanPrompt() { + return fmt.Errorf("must provide --title and --body when prompts are disabled") + } + if interactive { var legacyTemplateFile *string if opts.RepoOverride == "" { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 233cdb38e..d4b55e1de 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -75,9 +75,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.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") + if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { + return errors.New("--title or --fill required when prompts are disabled") } if opts.IsDraft && opts.WebMode { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index f3426702f..4d915778f 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -135,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 prompts are disabled", err.Error()) assert.Equal(t, "", output.String()) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 94e0c959c..ff21a6039 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -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 prompts are disabled")} } opts.InteractiveMode = true } else if methodFlags > 1 { diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 6abea963c..1949a2d78 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -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 prompts are disabled", }, { name: "multiple merge methods", diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 19f33ca84..a76a39573 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -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 prompts are disabled")} } opts.InteractiveMode = true } else if found == 0 && opts.Body != "" { diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 1dfe4a53d..2f4a66f83 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -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 prompts are disabled", }, { name: "mutually exclusive review types", diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index fc53b460e..22d1ce3a6 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -73,6 +73,15 @@ 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("must pass name argument when prompts disabled")} + } + if !opts.Internal && !opts.Private && !opts.Public { + opts.Public = true + } + } + if runF != nil { return runF(opts) } @@ -264,7 +273,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) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 8eb1a2423..1fd99c035 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -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") diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 04f76fbb8..b22e2501c 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -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] } From 4d3e26e92780daaccc0f586ff2ca0aa9e6f08097 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Thu, 10 Sep 2020 13:19:43 -0500 Subject: [PATCH 63/70] prompts disabled test for auth refresh the behavior for nontty vs. prompts disabled is pretty different so i opted for a new test, here --- pkg/cmd/auth/refresh/refresh_test.go | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index cba3c107a..46db2af52 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -18,11 +18,12 @@ import ( func Test_NewCmdRefresh(t *testing.T) { tests := []struct { - name string - cli string - wants RefreshOptions - wantsErr bool - tty bool + name string + cli string + wants RefreshOptions + wantsErr bool + tty bool + neverPrompt bool }{ { name: "tty no arguments", @@ -48,6 +49,22 @@ func Test_NewCmdRefresh(t *testing.T) { Hostname: "aline.cedrac", }, }, + { + 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, @@ -74,6 +91,7 @@ func Test_NewCmdRefresh(t *testing.T) { } io.SetStdinTTY(tt.tty) io.SetStdoutTTY(tt.tty) + io.SetNeverPrompt(tt.neverPrompt) argv, err := shlex.Split(tt.cli) assert.NoError(t, err) From 4ef8a75ae2414a72b116a26f1abcd3458d4d5435 Mon Sep 17 00:00:00 2001 From: Tony Rosler <43752286+trozler@users.noreply.github.com> Date: Fri, 11 Sep 2020 11:23:24 +0200 Subject: [PATCH 64/70] Handle runtime error on bad user input (#1652) --- cmd/gh/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 44357ffd6..e61bb1c6c 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -94,7 +94,7 @@ 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 { From e5466c04116d65add397c17928aff1e7a57178c6 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 10 Sep 2020 15:12:52 +0200 Subject: [PATCH 65/70] Improve string display width calculation --- go.mod | 4 +- go.sum | 2 + pkg/text/truncate.go | 99 +++++++++++++++++++-------------------- pkg/text/truncate_test.go | 21 +++++++++ 4 files changed, 73 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 42a005291..47afd50ae 100644 --- a/go.mod +++ b/go.mod @@ -14,15 +14,17 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.7 github.com/mattn/go-isatty v0.0.12 + 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/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.6.1 golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a - golang.org/x/text v0.3.3 + golang.org/x/text v0.3.3 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) diff --git a/go.sum b/go.sum index 107d9a245..f9f02eabe 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ 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= diff --git a/pkg/text/truncate.go b/pkg/text/truncate.go index 78cd05da2..98a924c3e 100644 --- a/pkg/text/truncate.go +++ b/pkg/text/truncate.go @@ -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 } diff --git a/pkg/text/truncate_test.go b/pkg/text/truncate_test.go index 85a399e80..95119e2ff 100644 --- a/pkg/text/truncate_test.go +++ b/pkg/text/truncate_test.go @@ -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) { From 1073c9740943224170623243b7502bdde3b3acbc Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 11 Sep 2020 11:36:24 -0500 Subject: [PATCH 66/70] review feedback --- cmd/gh/main.go | 2 +- internal/config/config_file_test.go | 2 +- internal/config/config_type.go | 10 +++++----- internal/config/config_type_test.go | 4 ++-- pkg/cmd/auth/login/login.go | 4 +--- pkg/cmd/auth/logout/logout.go | 2 +- pkg/cmd/auth/refresh/refresh.go | 2 +- pkg/cmd/issue/create/create.go | 30 +++++++++++++---------------- pkg/cmd/issue/create/create_test.go | 10 +--------- pkg/cmd/pr/create/create.go | 29 ++++++++++++++-------------- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/merge/merge.go | 2 +- pkg/cmd/pr/merge/merge_test.go | 2 +- pkg/cmd/pr/review/review.go | 2 +- pkg/cmd/pr/review/review_test.go | 2 +- pkg/cmd/repo/create/create.go | 2 +- 16 files changed, 46 insertions(+), 61 deletions(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index d4a1fe293..fb7a091bb 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -53,7 +53,7 @@ func main() { prompt, _ := cfg.Get("", "prompt") - if prompt == config.NeverPrompt { + if prompt == config.PromptsDisabled { cmdFactory.IOStreams.SetNeverPrompt(true) } diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 112221021..1eb97ab10 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -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# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: auto, never\nprompt: auto\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" diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 584bedabb..740955539 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -12,8 +12,8 @@ import ( const ( defaultGitProtocol = "https" - NeverPrompt = "never" - AutoPrompt = "auto" + PromptsDisabled = "disabled" + PromptsEnabled = "enabled" ) // This interface describes interacting with some persistent configuration for gh. @@ -172,13 +172,13 @@ func NewBlankRoot() *yaml.Node { Value: "", }, { - HeadComment: "When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: auto, never", + 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: AutoPrompt, + Value: PromptsEnabled, }, { HeadComment: "Aliases allow you to create nicknames for gh commands", @@ -490,7 +490,7 @@ func defaultFor(key string) string { case "git_protocol": return defaultGitProtocol case "prompt": - return AutoPrompt + return PromptsEnabled default: return "" } diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index 9a57e0c4a..6e561f634 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -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# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: auto, never\nprompt: auto\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# When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: auto, never\nprompt: auto\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()) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 42cd69f51..630677dff 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -74,9 +74,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm } } else { if !opts.IO.CanPrompt() { - // TODO this error message kind of sucks since the prompting might be disabled from being - // in a nontty or from the config setting. Is it enough info? - return &cmdutil.FlagError{Err: errors.New("--with-token required when prompts disabled")} + return &cmdutil.FlagError{Err: errors.New("--with-token required when not running interactively")} } } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 4a39f2666..78a3bb98f 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -49,7 +49,7 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co `), RunE: func(cmd *cobra.Command, args []string) error { if opts.Hostname == "" && !opts.IO.CanPrompt() { - return &cmdutil.FlagError{Err: errors.New("--hostname required when prompts disabled")} + return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} } if runF != nil { diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 939b6f5a6..e2a7829b4 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -58,7 +58,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. 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 prompts disabled")} + return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} } if runF != nil { diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index b91d5786c..2a8ce27c6 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -1,6 +1,7 @@ package create import ( + "errors" "fmt" "net/http" @@ -28,10 +29,9 @@ type CreateOptions struct { RepoOverride string WebMode bool - Title string - TitleProvided bool - Body string - BodyProvided bool + Title string + Body string + Interactive bool Assignees []string Labels []string @@ -61,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) } @@ -151,17 +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 && !opts.IO.CanPrompt() { - return fmt.Errorf("must provide --title and --body when prompts are disabled") - } - - if interactive { + if opts.Interactive { var legacyTemplateFile *string if opts.RepoOverride == "" { if rootDir, err := git.ToplevelDir(); err == nil { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index fb46e6e1e..ffa5d3365 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -78,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) { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d4b55e1de..d14be560c 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -28,19 +28,18 @@ type CreateOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) - RootDirOverride string + Interactive bool - RepoOverride string + 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 @@ -71,12 +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") - if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { - return errors.New("--title or --fill required when prompts are disabled") + 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 { @@ -242,9 +243,7 @@ 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 diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 4d915778f..1eecab69b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -135,7 +135,7 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { t.Fatal("expected error") } - assert.Equal(t, "--title or --fill required when prompts are disabled", err.Error()) + assert.Equal(t, "--title or --fill required when not running interactively", err.Error()) assert.Equal(t, "", output.String()) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index ff21a6039..7347a21aa 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -87,7 +87,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm } if methodFlags == 0 { if !opts.IO.CanPrompt() { - return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when prompts are disabled")} + return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not running interactively")} } opts.InteractiveMode = true } else if methodFlags > 1 { diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 1949a2d78..c29b794ae 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -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 prompts are disabled", + wantErr: "--merge, --rebase, or --squash required when not running interactively", }, { name: "multiple merge methods", diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index a76a39573..d51d109da 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -105,7 +105,7 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co if found == 0 && opts.Body == "" { if !opts.IO.CanPrompt() { - return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when prompts are disabled")} + 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 != "" { diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 2f4a66f83..b96ab8089 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -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 prompts are disabled", + wantErr: "--approve, --request-changes, or --comment required when not running interactively", }, { name: "mutually exclusive review types", diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 22d1ce3a6..d43b30f24 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -75,7 +75,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if !opts.IO.CanPrompt() { if opts.Name == "" { - return &cmdutil.FlagError{Err: errors.New("must pass name argument when prompts disabled")} + return &cmdutil.FlagError{Err: errors.New("must pass name argument when not running interactively")} } if !opts.Internal && !opts.Private && !opts.Public { opts.Public = true From 2e95e279e338c731e5d1a5a1872fb57c37fd4c4c Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 11 Sep 2020 11:38:26 -0500 Subject: [PATCH 67/70] reuse cfg reference --- cmd/gh/main.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index fb7a091bb..0ac9cdf15 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -102,12 +102,6 @@ func main() { os.Getenv("GITHUB_ENTERPRISE_TOKEN") == "" && 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) From a811e72c9d1073e45e0c7ecbe371cf11bf3b7ec7 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 11 Sep 2020 11:57:40 -0500 Subject: [PATCH 68/70] do not assume public when creating repo non-interactively --- pkg/cmd/repo/create/create.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index d43b30f24..fb37e27ed 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -75,10 +75,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if !opts.IO.CanPrompt() { if opts.Name == "" { - return &cmdutil.FlagError{Err: errors.New("must pass name argument when not running interactively")} + return &cmdutil.FlagError{Err: errors.New("name argument required when not running interactively")} } + if !opts.Internal && !opts.Private && !opts.Public { - opts.Public = true + return &cmdutil.FlagError{Err: errors.New("--public, --private, or --internal required when not running interactively")} } } From c3863e35b4b72adcefa71ae82160d3ed32459b58 Mon Sep 17 00:00:00 2001 From: Amanda Pinsker Date: Mon, 14 Sep 2020 12:40:56 -0400 Subject: [PATCH 69/70] Add prompt to gh config help --- pkg/cmd/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 3fa86ddcb..6dd0f528c 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -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. `), } From db77f2dc66c5ece25ea96b36dffb5c475ec52771 Mon Sep 17 00:00:00 2001 From: Amanda Pinsker Date: Mon, 14 Sep 2020 12:42:13 -0400 Subject: [PATCH 70/70] Add examples --- pkg/cmd/config/config.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 6dd0f528c..e5e9d170a 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -73,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 {