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