mirror of https://gitlab.com/gitlab-org/cli.git
feat: add recover option to issue create command
This commit is contained in:
parent
086363bb98
commit
64d1477946
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{"field":"field","number":123,"boolean":true}
|
Loading…
Reference in New Issue