mirror of https://gitlab.com/gitlab-org/cli.git
feat(ci-delete): add flags for filtering and pagination
This commit is contained in:
parent
42ce37175a
commit
9a99b8399b
|
@ -2,8 +2,14 @@ package delete
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"gitlab.com/gitlab-org/cli/internal/glrepo"
|
||||
"gitlab.com/gitlab-org/cli/pkg/iostreams"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
|
||||
|
@ -14,24 +20,52 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagDryRun = "dry-run"
|
||||
FlagOlderThan = "older-than"
|
||||
FlagPage = "page"
|
||||
FlagPaginate = "paginate"
|
||||
FlagPerPage = "per-page"
|
||||
FlagSource = "source"
|
||||
FlagStatus = "status"
|
||||
)
|
||||
|
||||
var (
|
||||
pipelineStatuses = []string{"running", "pending", "success", "failed", "canceled", "skipped", "created", "manual"}
|
||||
pipelineSources = []string{
|
||||
"api", "chat", "external", "external_pull_request_event", "merge_request_event",
|
||||
"ondemand_dast_scan", "ondemand_dast_validation", "parent_pipeline", "pipeline",
|
||||
"push", "schedule", "security_orchestration_policy", "trigger", "web", "webide",
|
||||
}
|
||||
)
|
||||
|
||||
func NewCmdDelete(f *cmdutils.Factory) *cobra.Command {
|
||||
pipelineDeleteCmd := &cobra.Command{
|
||||
Use: "delete <id> [flags]",
|
||||
Short: `Delete a CI/CD pipeline`,
|
||||
Short: `Delete CI/CD pipelines`,
|
||||
Example: heredoc.Doc(`
|
||||
glab ci delete 34
|
||||
glab ci delete 12,34,2
|
||||
glab ci delete --source=api
|
||||
glab ci delete --status=failed
|
||||
glab ci delete --older-than 24h
|
||||
glab ci delete --older-than 24h --status=failed
|
||||
`),
|
||||
Long: ``,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if m, _ := cmd.Flags().GetString("status"); m != "" && len(args) > 0 {
|
||||
return fmt.Errorf("either a status filter or a pipeline id must be passed, but not both")
|
||||
} else if m == "" {
|
||||
olderThanDuration, _ := cmd.Flags().GetDuration(FlagOlderThan)
|
||||
status, _ := cmd.Flags().GetString(FlagStatus)
|
||||
source, _ := cmd.Flags().GetString(FlagSource)
|
||||
|
||||
if olderThanDuration == 0 && status == "" && source == "" {
|
||||
return cobra.ExactArgs(1)(cmd, args)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("either a status filter or a pipeline ID must be passed, but not both")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
|
@ -46,49 +80,124 @@ func NewCmdDelete(f *cmdutils.Factory) *cobra.Command {
|
|||
return err
|
||||
}
|
||||
|
||||
var pipelineIDs []int
|
||||
dryRunMode, _ := cmd.Flags().GetBool(FlagDryRun)
|
||||
|
||||
if m, _ := cmd.Flags().GetString("status"); m != "" {
|
||||
pipes, err := api.ListProjectPipelines(apiClient, repo.FullName(), &gitlab.ListProjectPipelinesOptions{
|
||||
Status: gitlab.BuildState(gitlab.BuildStateValue(m)),
|
||||
})
|
||||
var pipelineIDs []int
|
||||
if len(args) == 1 {
|
||||
pipelineIDs, err = parseRawPipelineIDs(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range pipes {
|
||||
pipelineIDs = append(pipelineIDs, item.ID)
|
||||
}
|
||||
} else {
|
||||
for _, stringID := range strings.Split(strings.Trim(args[0], "[] "), ",") {
|
||||
id, err := strconv.Atoi(stringID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipelineIDs = append(pipelineIDs, id)
|
||||
}
|
||||
return runDeletion(pipelineIDs, dryRunMode, f.IO.StdOut, c, apiClient, repo)
|
||||
}
|
||||
|
||||
for _, id := range pipelineIDs {
|
||||
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
|
||||
fmt.Fprintf(f.IO.StdOut, "%s Pipeline #%d will be deleted\n", c.DotWarnIcon(), id)
|
||||
} else {
|
||||
err := api.DeletePipeline(apiClient, repo.FullName(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paginate, _ := cmd.Flags().GetBool(FlagPaginate)
|
||||
|
||||
fmt.Fprintf(f.IO.StdOut, "%s Pipeline #%d deleted successfully\n", c.RedCheck(), id)
|
||||
}
|
||||
pipelineIDs, err = listPipelineIDs(apiClient, repo.FullName(), paginate, optsFromFlags(cmd.Flags()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
return runDeletion(pipelineIDs, dryRunMode, f.IO.StdOut, c, apiClient, repo)
|
||||
},
|
||||
}
|
||||
|
||||
pipelineDeleteCmd.Flags().BoolP("dry-run", "", false, "simulate process, but do not delete anything")
|
||||
pipelineDeleteCmd.Flags().StringP("status", "s", "", "delete pipelines by status: {running|pending|success|failed|canceled|skipped|created|manual}")
|
||||
SetupCommandFlags(pipelineDeleteCmd.Flags())
|
||||
|
||||
return pipelineDeleteCmd
|
||||
}
|
||||
|
||||
func SetupCommandFlags(flags *pflag.FlagSet) {
|
||||
flags.BoolP(FlagDryRun, "", false, "Simulate process, but do not delete anything")
|
||||
flags.StringP(FlagStatus, "s", "", fmt.Sprintf("Delete pipelines by status: {%s}", strings.Join(pipelineStatuses, "|")))
|
||||
flags.String(FlagSource, "", fmt.Sprintf("Filter pipelines by source: {%s}", strings.Join(pipelineSources, "|")))
|
||||
flags.Duration(FlagOlderThan, 0, "Filter pipelines older than the given duration. Valid units: {h|m|s|ms|us|ns}")
|
||||
flags.BoolP(FlagPaginate, "", false, "Make additional HTTP requests to fetch all pages of projects before cloning. Respects --per-page")
|
||||
flags.IntP(FlagPage, "", 0, "Page number")
|
||||
flags.IntP(FlagPerPage, "", 0, "Number of items to list per page")
|
||||
}
|
||||
|
||||
func optsFromFlags(flags *pflag.FlagSet) *gitlab.ListProjectPipelinesOptions {
|
||||
opts := &gitlab.ListProjectPipelinesOptions{}
|
||||
page, _ := flags.GetInt(FlagPage)
|
||||
perPage, _ := flags.GetInt(FlagPerPage)
|
||||
|
||||
if perPage != 0 {
|
||||
opts.PerPage = perPage
|
||||
}
|
||||
if page != 0 {
|
||||
opts.Page = page
|
||||
}
|
||||
|
||||
source, _ := flags.GetString(FlagSource)
|
||||
status, _ := flags.GetString(FlagStatus)
|
||||
olderThanDuration, _ := flags.GetDuration(FlagOlderThan)
|
||||
|
||||
if source != "" {
|
||||
opts.Source = gitlab.String(source)
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
opts.Status = gitlab.BuildState(gitlab.BuildStateValue(status))
|
||||
}
|
||||
|
||||
if olderThanDuration != 0 {
|
||||
opts.UpdatedBefore = gitlab.Time(time.Now().Add(-olderThanDuration))
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func parseRawPipelineIDs(rawPipelineIDs string) ([]int, error) {
|
||||
var inputPipelineIDs []int
|
||||
for _, stringID := range strings.Split(rawPipelineIDs, ",") {
|
||||
id, err := strconv.Atoi(stringID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputPipelineIDs = append(inputPipelineIDs, id)
|
||||
}
|
||||
|
||||
return inputPipelineIDs, nil
|
||||
}
|
||||
|
||||
func runDeletion(pipelineIDs []int, dryRunMode bool, w io.Writer, c *iostreams.ColorPalette, apiClient *gitlab.Client, repo glrepo.Interface) error {
|
||||
for _, id := range pipelineIDs {
|
||||
if dryRunMode {
|
||||
fmt.Fprintf(w, "%s Pipeline #%d will be deleted\n", c.DotWarnIcon(), id)
|
||||
continue
|
||||
}
|
||||
|
||||
err := api.DeletePipeline(apiClient, repo.FullName(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s Pipeline #%d deleted successfully\n", c.RedCheck(), id)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listPipelineIDs(apiClient *gitlab.Client, repoName string, paginate bool, opts *gitlab.ListProjectPipelinesOptions) ([]int, error) {
|
||||
var pipelineIDs []int
|
||||
|
||||
hasRemaining := true
|
||||
for hasRemaining {
|
||||
pipes, resp, err := apiClient.Pipelines.ListProjectPipelines(repoName, opts)
|
||||
if err != nil {
|
||||
return pipelineIDs, err
|
||||
}
|
||||
|
||||
for _, item := range pipes {
|
||||
pipelineIDs = append(pipelineIDs, item.ID)
|
||||
}
|
||||
|
||||
opts.Page = resp.NextPage
|
||||
hasRemaining = paginate && resp.CurrentPage != resp.TotalPages
|
||||
}
|
||||
|
||||
return pipelineIDs, nil
|
||||
}
|
||||
|
|
|
@ -3,6 +3,10 @@ package delete
|
|||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/xanzy/go-gitlab"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
@ -27,7 +31,7 @@ func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) {
|
|||
return cmdtest.ExecuteCommand(cmd, cli, stdout, stderr)
|
||||
}
|
||||
|
||||
func TestCiDelete(t *testing.T) {
|
||||
func TestCIDelete(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
|
@ -49,7 +53,39 @@ func TestCiDelete(t *testing.T) {
|
|||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDeleteByStatus(t *testing.T) {
|
||||
func TestCIDeleteNonExistingPipeline(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodDelete, "/api/v4/projects/OWNER/REPO/pipelines/11111111",
|
||||
httpmock.NewJSONResponse(http.StatusNotFound, "{message: 404 Not found}"),
|
||||
)
|
||||
|
||||
pipelineId := "11111111"
|
||||
output, err := runCommand(fakeHTTP, pipelineId)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
out := output.String()
|
||||
|
||||
assert.Empty(t, out)
|
||||
}
|
||||
|
||||
func TestCIDeleteWithWrongArgument(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
pipelineId := "test"
|
||||
output, err := runCommand(fakeHTTP, pipelineId)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
out := output.String()
|
||||
|
||||
assert.Empty(t, out)
|
||||
}
|
||||
|
||||
func TestCIDeleteByStatus(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
@ -103,20 +139,20 @@ func TestCiDeleteByStatus(t *testing.T) {
|
|||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDeleteByStatusFailsWithArgument(t *testing.T) {
|
||||
func TestCIDeleteByStatusFailsWithArgument(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
args := "--status=success 11111111"
|
||||
output, err := runCommand(fakeHTTP, args)
|
||||
assert.EqualError(t, err, "either a status filter or a pipeline id must be passed, but not both")
|
||||
assert.EqualError(t, err, "either a status filter or a pipeline ID must be passed, but not both")
|
||||
|
||||
assert.Empty(t, output.String())
|
||||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDeleteWithoutFilterFailsWithoutArgument(t *testing.T) {
|
||||
func TestCIDeleteWithoutFilterFailsWithoutArgument(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
@ -129,7 +165,7 @@ func TestCiDeleteWithoutFilterFailsWithoutArgument(t *testing.T) {
|
|||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDeleteMultiple(t *testing.T) {
|
||||
func TestCIDeleteMultiple(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
|
@ -155,7 +191,7 @@ func TestCiDeleteMultiple(t *testing.T) {
|
|||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDryRunDeleteNothing(t *testing.T) {
|
||||
func TestCIDryRunDeleteNothing(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
|
@ -174,7 +210,7 @@ func TestCiDryRunDeleteNothing(t *testing.T) {
|
|||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCiDeletedDryRunWithFilterDoesNotDelete(t *testing.T) {
|
||||
func TestCIDeletedDryRunWithFilterDoesNotDelete(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
@ -221,3 +257,102 @@ func TestCiDeletedDryRunWithFilterDoesNotDelete(t *testing.T) {
|
|||
`), out)
|
||||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestCIDeleteBySource(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO/pipelines?source=push",
|
||||
httpmock.NewStringResponse(http.StatusOK, `
|
||||
[
|
||||
{
|
||||
"id": 22222222,
|
||||
"iid": 4,
|
||||
"project_id": 5,
|
||||
"sha": "c9a7c0d9351cd1e71d1c2ad8277f3bc7e3c47d1f",
|
||||
"ref": "main",
|
||||
"status": "success",
|
||||
"source": "push",
|
||||
"created_at": "2020-11-30T18:20:47.571Z",
|
||||
"updated_at": "2020-11-30T18:39:40.092Z",
|
||||
"web_url": "https://gitlab.com/OWNER/REPO/-/pipelines/709793838"
|
||||
}
|
||||
]
|
||||
`))
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodDelete, "/api/v4/projects/OWNER/REPO/pipelines/22222222",
|
||||
httpmock.NewStringResponse(http.StatusNoContent, ""),
|
||||
)
|
||||
|
||||
args := "--source=push"
|
||||
output, err := runCommand(fakeHTTP, args)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := output.String()
|
||||
|
||||
assert.Equal(t, heredoc.Doc(`
|
||||
✓ Pipeline #22222222 deleted successfully
|
||||
`), out)
|
||||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestParseRawPipelineIDsCorrectly(t *testing.T) {
|
||||
pipelineIDs, err := parseRawPipelineIDs("1,2,3")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int{1, 2, 3}, pipelineIDs)
|
||||
}
|
||||
|
||||
func TestParseRawPipelineIDsWithError(t *testing.T) {
|
||||
pipelineIDs, err := parseRawPipelineIDs("test")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Len(t, pipelineIDs, 0)
|
||||
}
|
||||
|
||||
func TestExtractPipelineIDsFromFlagsWithError(t *testing.T) {
|
||||
fakeHTTP := httpmock.New()
|
||||
fakeHTTP.MatchURL = httpmock.PathAndQuerystring
|
||||
defer fakeHTTP.Verify(t)
|
||||
|
||||
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO/pipelines?status=success",
|
||||
httpmock.NewStringResponse(http.StatusForbidden, `{message: 403 Forbidden}`))
|
||||
|
||||
args := "--status=success"
|
||||
output, err := runCommand(fakeHTTP, args)
|
||||
require.Error(t, err)
|
||||
|
||||
out := output.String()
|
||||
|
||||
assert.Empty(t, out)
|
||||
assert.Empty(t, output.Stderr())
|
||||
}
|
||||
|
||||
func TestOptsFromFlags(t *testing.T) {
|
||||
flags := pflag.NewFlagSet("test-flagset", pflag.ContinueOnError)
|
||||
SetupCommandFlags(flags)
|
||||
|
||||
require.NoError(t, flags.Parse([]string{"--status", "success", "--older-than", "24h"}))
|
||||
|
||||
opts := optsFromFlags(flags)
|
||||
|
||||
assert.Nil(t, opts.Source)
|
||||
assert.Equal(t, opts.Status, gitlab.BuildState("success"))
|
||||
|
||||
lowerTimeBoundary := time.Now().Add(-1 * 24 * time.Hour).Add(-5 * time.Second)
|
||||
upperTimeBoundary := time.Now().Add(-1 * 24 * time.Hour).Add(5 * time.Second)
|
||||
assert.WithinRange(t, *opts.UpdatedBefore, lowerTimeBoundary, upperTimeBoundary)
|
||||
}
|
||||
|
||||
func TestOptsFromFlagsWithPagination(t *testing.T) {
|
||||
flags := pflag.NewFlagSet("test-flagset", pflag.ContinueOnError)
|
||||
SetupCommandFlags(flags)
|
||||
|
||||
require.NoError(t, flags.Parse([]string{"--page", "5", "--per-page", "10"}))
|
||||
|
||||
opts := optsFromFlags(flags)
|
||||
|
||||
assert.Equal(t, opts.Page, 5)
|
||||
assert.Equal(t, opts.PerPage, 10)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ Please do not edit this file directly. Run `make gen-docs` instead.
|
|||
|
||||
# `glab ci delete`
|
||||
|
||||
Delete a CI/CD pipeline
|
||||
Delete CI/CD pipelines
|
||||
|
||||
```plaintext
|
||||
glab ci delete <id> [flags]
|
||||
|
@ -22,15 +22,23 @@ glab ci delete <id> [flags]
|
|||
```plaintext
|
||||
glab ci delete 34
|
||||
glab ci delete 12,34,2
|
||||
glab ci delete --source=api
|
||||
glab ci delete --status=failed
|
||||
glab ci delete --older-than 24h
|
||||
glab ci delete --older-than 24h --status=failed
|
||||
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```plaintext
|
||||
--dry-run simulate process, but do not delete anything
|
||||
-s, --status string delete pipelines by status: {running|pending|success|failed|canceled|skipped|created|manual}
|
||||
--dry-run Simulate process, but do not delete anything
|
||||
--older-than duration Filter pipelines older than the given duration. Valid units: {h|m|s|ms|us|ns}
|
||||
--page int Page number
|
||||
--paginate Make additional HTTP requests to fetch all pages of projects before cloning. Respects --per-page
|
||||
--per-page int Number of items to list per page
|
||||
--source string Filter pipelines by source: {api|chat|external|external_pull_request_event|merge_request_event|ondemand_dast_scan|ondemand_dast_validation|parent_pipeline|pipeline|push|schedule|security_orchestration_policy|trigger|web|webide}
|
||||
-s, --status string Delete pipelines by status: {running|pending|success|failed|canceled|skipped|created|manual}
|
||||
```
|
||||
|
||||
## Options inherited from parent commands
|
||||
|
|
Loading…
Reference in New Issue