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("✓") +}