feat: add recover option to issue create command

This commit is contained in:
Jaime Martinez 2022-12-06 06:31:33 +00:00 committed by Tomas Vik
parent 086363bb98
commit 64d1477946
5 changed files with 230 additions and 33 deletions

View File

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
@ -20,39 +21,41 @@ import (
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/commands/issue/issueutils"
"gitlab.com/gitlab-org/cli/internal/recovery"
"gitlab.com/gitlab-org/cli/pkg/prompt"
)
type CreateOpts struct {
Title string
Description string
Labels []string
Assignees []string
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Labels []string `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Weight int
MileStone int
LinkedMR int
LinkedIssues []int
IssueLinkType string
TimeEstimate string
TimeSpent string
Weight int `json:"weight,omitempty"`
Milestone int `json:"milestone,omitempty"`
LinkedMR int `json:"linked_mr,omitempty"`
LinkedIssues []int `json:"linked_issues,omitempty"`
IssueLinkType string `json:"issue_link_type,omitempty"`
TimeEstimate string `json:"time_estimate,omitempty"`
TimeSpent string `json:"time_spent,omitempty"`
MilestoneFlag string
MilestoneFlag string `json:"milestone_flag"`
NoEditor bool
IsConfidential bool
IsInteractive bool
OpenInWeb bool
Yes bool
Web bool
NoEditor bool `json:"-"`
IsConfidential bool `json:"is_confidential,omitempty"`
IsInteractive bool `json:"-"`
OpenInWeb bool `json:"-"`
Yes bool `json:"-"`
Web bool `json:"-"`
Recover bool `json:"-"`
IO *iostreams.IOStreams
BaseRepo func() (glrepo.Interface, error)
HTTPClient func() (*gitlab.Client, error)
Remotes func() (glrepo.Remotes, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams `json:"-"`
BaseRepo func() (glrepo.Interface, error) `json:"-"`
HTTPClient func() (*gitlab.Client, error) `json:"-"`
Remotes func() (glrepo.Remotes, error) `json:"-"`
Config func() (config.Config, error) `json:"-"`
BaseProject *gitlab.Project
BaseProject *gitlab.Project `json:"-"`
}
func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
@ -71,7 +74,7 @@ func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
glab issue new
glab issue create -m release-2.0.0 -t "we need this feature" --label important
glab issue new -t "Fix CVE-YYYY-XXXX" -l security --linked-mr 123
glab issue create -m release-1.0.1 -t "security fix" --label security --web
glab issue create -m release-1.0.1 -t "security fix" --label security --web --recover
`),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
@ -120,7 +123,17 @@ func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
return cmdutils.SilentError
}
return createRun(opts)
if err := createRun(opts); err != nil {
// always save options to file
recoverErr := createRecoverSaveFile(repo.FullName(), opts)
if recoverErr != nil {
fmt.Fprintf(opts.IO.StdErr, "Could not create recovery file: %v", recoverErr)
}
return err
}
return nil
},
}
issueCreateCmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title for issue")
@ -138,6 +151,7 @@ func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
issueCreateCmd.Flags().StringVarP(&opts.IssueLinkType, "link-type", "", "relates_to", "Type for the issue link")
issueCreateCmd.Flags().StringVarP(&opts.TimeEstimate, "time-estimate", "e", "", "Set time estimate for the issue")
issueCreateCmd.Flags().StringVarP(&opts.TimeSpent, "time-spent", "s", "", "Set time spent for the issue")
issueCreateCmd.Flags().BoolVar(&opts.Recover, "recover", false, "Save the options to a file if the issue fails to be created. If the file exists, the options will be loaded from the recovery file (EXPERIMENTAL)")
return issueCreateCmd
}
@ -159,12 +173,23 @@ func createRun(opts *CreateOpts) error {
issueCreateOpts := &gitlab.CreateIssueOptions{}
if opts.MilestoneFlag != "" {
opts.MileStone, err = cmdutils.ParseMilestone(apiClient, repo, opts.MilestoneFlag)
opts.Milestone, err = cmdutils.ParseMilestone(apiClient, repo, opts.MilestoneFlag)
if err != nil {
return err
}
}
if opts.Recover {
if err := recovery.FromFile(repo.FullName(), "issue.json", opts); err != nil {
// if the file to recover doesn't exist, we can just ignore the error and move on
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(opts.IO.StdErr, "Failed to recover from file: %v", err)
}
} else {
fmt.Fprintln(opts.IO.StdOut, "Recovered create options from file")
}
}
if opts.IsInteractive {
if opts.Description == "" {
if opts.NoEditor {
@ -290,7 +315,7 @@ func createRun(opts *CreateOpts) error {
}
}
if x == cmdutils.AddMilestoneAction {
err = cmdutils.MilestonesPrompt(&opts.MileStone, apiClient, repoRemote, opts.IO)
err = cmdutils.MilestonesPrompt(&opts.Milestone, apiClient, repoRemote, opts.IO)
if err != nil {
return err
}
@ -327,8 +352,8 @@ func createRun(opts *CreateOpts) error {
if opts.LinkedMR != 0 {
issueCreateOpts.MergeRequestToResolveDiscussionsOf = gitlab.Int(opts.LinkedMR)
}
if opts.MileStone != 0 {
issueCreateOpts.MilestoneID = gitlab.Int(opts.MileStone)
if opts.Milestone != 0 {
issueCreateOpts.MilestoneID = gitlab.Int(opts.Milestone)
}
if len(opts.Assignees) > 0 {
users, err := api.UsersByNames(apiClient, opts.Assignees)
@ -427,9 +452,9 @@ func generateIssueWebURL(opts *CreateOpts) (string, error) {
// this uses the slash commands to add assignees to the description
description += fmt.Sprintf("\n/assign %s", strings.Join(opts.Assignees, ", "))
}
if opts.MileStone != 0 {
if opts.Milestone != 0 {
// this uses the slash commands to add milestone to the description
description += fmt.Sprintf("\n/milestone %%%d", opts.MileStone)
description += fmt.Sprintf("\n/milestone %%%d", opts.Milestone)
}
if opts.Weight != 0 {
// this uses the slash commands to add weight to the description
@ -449,5 +474,17 @@ func generateIssueWebURL(opts *CreateOpts) (string, error) {
"issue[title]=%s&issue[description]=%s",
strings.ReplaceAll(url.PathEscape(opts.Title), "+", "%2B"),
strings.ReplaceAll(url.PathEscape(description), "+", "%2B"))
return u.String(), nil
}
// createRecoverSaveFile will try save the issue create options to a file
func createRecoverSaveFile(repoName string, opts *CreateOpts) error {
recoverFile, err := recovery.CreateFile(repoName, "issue.json", opts)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.StdErr, "Failed to create issue. Created recovery file: %s\nRun the command again with the '--recover' option to retry", recoverFile)
return nil
}

View File

@ -24,7 +24,7 @@ glab issue create
glab issue new
glab issue create -m release-2.0.0 -t "we need this feature" --label important
glab issue new -t "Fix CVE-YYYY-XXXX" -l security --linked-mr 123
glab issue create -m release-1.0.1 -t "security fix" --label security --web
glab issue create -m release-1.0.1 -t "security fix" --label security --web --recover
```
@ -40,6 +40,7 @@ glab issue create -m release-1.0.1 -t "security fix" --label security --web
--linked-mr int The IID of a merge request in which to resolve all issues
-m, --milestone string The global ID or title of a milestone to assign
--no-editor Don't open editor to enter description. If set to true, uses prompt. Default is false
--recover Save the options to a file if the issue fails to be created. If the file exists, the options will be loaded from the recovery file (EXPERIMENTAL)
-e, --time-estimate string Set time estimate for the issue
-s, --time-spent string Set time spent for the issue
-t, --title string Supply a title for issue

View File

@ -0,0 +1,75 @@
package recovery
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gitlab.com/gitlab-org/cli/internal/config"
)
const recoverDir = "recover"
func getRecoverDir(repoName string) (string, error) {
configDir := config.ConfigDir()
dir := filepath.Join(configDir, recoverDir, repoName)
if config.CheckPathExists(dir) {
return dir, nil
}
err := os.MkdirAll(dir, 0o755)
if err != nil {
return "", fmt.Errorf("creating recovery directory: %w", err)
}
return dir, nil
}
// CreateFile will create a filename under the recoverDir which lives inside
// the config.ConfigDir
func CreateFile(repoName, filename string, i any) (string, error) {
dir, err := getRecoverDir(repoName)
if err != nil {
return "", err
}
fullPath := filepath.Join(dir, filename)
f, err := os.Create(fullPath)
if err != nil {
return "", fmt.Errorf("creating recovery file: %w", err)
}
defer f.Close()
if err := json.NewEncoder(f).Encode(i); err != nil {
return "", fmt.Errorf("writing file: %w", err)
}
return fullPath, nil
}
// FromFile will try to open the filename and unmarshal the
// contents into a struct i of any type
func FromFile(repoName, fileName string, i any) error {
dir, err := getRecoverDir(repoName)
if err != nil {
return err
}
fullPath := filepath.Join(dir, fileName)
f, err := os.Open(fullPath)
if err != nil {
return err
}
if err := json.NewDecoder(f).Decode(&i); err != nil {
return fmt.Errorf("could not decode %s into struct: %w", fileName, err)
}
// close and remove file
f.Close()
return os.Remove(fullPath)
}

View File

@ -0,0 +1,83 @@
package recovery_test
import (
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/internal/recovery"
)
type S struct {
Field string `json:"field,omitempty"`
Number int `json:"number,omitempty"`
Boolean bool `json:"boolean,omitempty"`
}
var sample = S{
Field: "field",
Number: 123,
Boolean: true,
}
func TestCreateRecoverFile(t *testing.T) {
d := t.TempDir()
t.Setenv("GLAB_CONFIG_DIR", d)
tmpFile, err := recovery.CreateFile("repo/name", "struct.json", &sample)
require.NoError(t, err)
fi, err := os.Stat(tmpFile)
require.NoError(t, err)
require.NotZero(t, fi.Size())
f, err := os.Open(tmpFile)
require.NoError(t, err)
b, err := io.ReadAll(f)
require.NoError(t, err)
expected, err := json.Marshal(sample)
require.NoError(t, err)
require.Equal(t, string(expected)+"\n", string(b))
}
func TestFromFile(t *testing.T) {
wd, err := os.Getwd()
require.NoError(t, err)
// load file contents from ./testdata/struct.json
d := filepath.Join(wd, "testdata")
t.Setenv("GLAB_CONFIG_DIR", d)
defer func() {
// create file again because `recovery.FromFile` removes it at the end
_, err := recovery.CreateFile("repo/name", "struct.json", sample)
require.NoError(t, err)
}()
var got S
err = recovery.FromFile("repo/name", "struct.json", &got)
require.NoError(t, err)
require.Equal(t, sample, got)
}
func TestRecovery(t *testing.T) {
d := t.TempDir()
t.Setenv("GLAB_CONFIG_DIR", d)
_, err := recovery.CreateFile("repo/name", "struct.json", &sample)
require.NoError(t, err)
var got S
err = recovery.FromFile("repo/name", "struct.json", &got)
require.NoError(t, err)
require.Equal(t, sample, got)
}

View File

@ -0,0 +1 @@
{"field":"field","number":123,"boolean":true}