feat(ci-delete): add flags for filtering and pagination

This commit is contained in:
Guido Pili 2023-12-06 16:26:11 +00:00 committed by Oscar Tovar
parent 42ce37175a
commit 9a99b8399b
3 changed files with 299 additions and 47 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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