Verified Commit 83629939 authored by Carlos Corona's avatar Carlos Corona Committed by GitLab
Browse files

feat(workitems): add DeleteWorkItem method

Changelog: Improvements
parent 4a36c618
Loading
Loading
Loading
Loading
+0 −14
Original line number Diff line number Diff line
@@ -275,17 +275,3 @@ func CreateTestEpicWithOptions(t *testing.T, client *gitlab.Client, gid any, opt

	return epic, nil
}

// CreateTestWorkItemWithOptions creates a work item with the provided options.
func CreateTestWorkItem(t *testing.T, client *gitlab.Client, fullPath string, workItemTypeID gitlab.WorkItemTypeID, opts *gitlab.CreateWorkItemOptions) (*gitlab.WorkItem, error) {
	t.Helper()

	wi, _, err := client.WorkItems.CreateWorkItem(fullPath, workItemTypeID, opts, gitlab.WithContext(t.Context()))
	if err != nil {
		return nil, err
	}

	// TODO: install a t.Cleanup() function once DeleteWorkItem has been implemented.

	return wi, nil
}
+22 −2
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@
package gitlab_test

import (
	"context"
	"errors"
	"testing"

	"github.com/stretchr/testify/assert"
@@ -10,7 +12,7 @@ import (
	gitlab "gitlab.com/gitlab-org/api/client-go/v2"
)

func TestCreateGetUpdateWorkItem(t *testing.T) {
func TestWorkItemLifeCycle(t *testing.T) {
	t.Parallel()

	client := SetupIntegrationClient(t)
@@ -28,10 +30,19 @@ func TestCreateGetUpdateWorkItem(t *testing.T) {
		Color:        gitlab.Ptr("green"),
	}

	createdWI, err := CreateTestWorkItem(t, client, group.FullPath, gitlab.WorkItemTypeEpic, &createOpt)
	createdWI, _, err := client.WorkItems.CreateWorkItem(group.FullPath, gitlab.WorkItemTypeEpic, &createOpt)
	require.NoError(t, err, "CreateWorkItem failed")
	require.NotNil(t, createdWI)

	// clean up in case test fails too early
	t.Cleanup(func() {
		_, err := client.WorkItems.DeleteWorkItem(group.FullPath, createdWI.IID, gitlab.WithContext(context.Background()))
		if err != nil && errors.Is(err, gitlab.ErrNotFound) {
			return
		}
		require.NoError(t, err, "Failed to delete test work item in cleanup")
	})

	// THEN the work item should have the provided fields set correctly
	assert.Equal(t, "Integration Test Work Item", createdWI.Title, "Field: Title")
	assert.Equal(t, "Initial description", createdWI.Description, "Field: Description")
@@ -80,6 +91,15 @@ func TestCreateGetUpdateWorkItem(t *testing.T) {
	assert.Equal(t, updatedWI.Description, finalWI.Description, "Field: Description")
	assert.Equal(t, deref(t, updatedWI.HealthStatus), deref(t, finalWI.HealthStatus), "Field: HealthStatus")
	assert.Equal(t, deref(t, updatedWI.Color), deref(t, finalWI.Color), "Field: Color")

	// STEP 5: Delete the work item
	// WHEN deleting the work item
	_, err = client.WorkItems.DeleteWorkItem(group.FullPath, createdWI.IID)
	require.NoError(t, err, "DeleteWorkItem failed")

	// THEN the work item should no longer be retrievable
	_, _, err = client.WorkItems.GetWorkItem(group.FullPath, createdWI.IID)
	require.ErrorIs(t, err, gitlab.ErrNotFound)
}

func deref(t *testing.T, ptr *string) string {
+44 −0
Original line number Diff line number Diff line
@@ -84,6 +84,50 @@ func (c *MockWorkItemsServiceInterfaceCreateWorkItemCall) DoAndReturn(f func(str
	return c
}

// DeleteWorkItem mocks base method.
func (m *MockWorkItemsServiceInterface) DeleteWorkItem(fullPath string, iid int64, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
	m.ctrl.T.Helper()
	varargs := []any{fullPath, iid}
	for _, a := range options {
		varargs = append(varargs, a)
	}
	ret := m.ctrl.Call(m, "DeleteWorkItem", varargs...)
	ret0, _ := ret[0].(*gitlab.Response)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// DeleteWorkItem indicates an expected call of DeleteWorkItem.
func (mr *MockWorkItemsServiceInterfaceMockRecorder) DeleteWorkItem(fullPath, iid any, options ...any) *MockWorkItemsServiceInterfaceDeleteWorkItemCall {
	mr.mock.ctrl.T.Helper()
	varargs := append([]any{fullPath, iid}, options...)
	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkItem", reflect.TypeOf((*MockWorkItemsServiceInterface)(nil).DeleteWorkItem), varargs...)
	return &MockWorkItemsServiceInterfaceDeleteWorkItemCall{Call: call}
}

// MockWorkItemsServiceInterfaceDeleteWorkItemCall wrap *gomock.Call
type MockWorkItemsServiceInterfaceDeleteWorkItemCall struct {
	*gomock.Call
}

// Return rewrite *gomock.Call.Return
func (c *MockWorkItemsServiceInterfaceDeleteWorkItemCall) Return(arg0 *gitlab.Response, arg1 error) *MockWorkItemsServiceInterfaceDeleteWorkItemCall {
	c.Call = c.Call.Return(arg0, arg1)
	return c
}

// Do rewrite *gomock.Call.Do
func (c *MockWorkItemsServiceInterfaceDeleteWorkItemCall) Do(f func(string, int64, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)) *MockWorkItemsServiceInterfaceDeleteWorkItemCall {
	c.Call = c.Call.Do(f)
	return c
}

// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockWorkItemsServiceInterfaceDeleteWorkItemCall) DoAndReturn(f func(string, int64, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)) *MockWorkItemsServiceInterfaceDeleteWorkItemCall {
	c.Call = c.Call.DoAndReturn(f)
	return c
}

// GetWorkItem mocks base method.
func (m *MockWorkItemsServiceInterface) GetWorkItem(fullPath string, iid int64, options ...gitlab.RequestOptionFunc) (*gitlab.WorkItem, *gitlab.Response, error) {
	m.ctrl.T.Helper()
+73 −16
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ type (
		GetWorkItem(fullPath string, iid int64, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		ListWorkItems(fullPath string, opt *ListWorkItemsOptions, options ...RequestOptionFunc) ([]*WorkItem, *Response, error)
		UpdateWorkItem(fullPath string, iid int64, opt *UpdateWorkItemOptions, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		DeleteWorkItem(fullPath string, iid int64, options ...RequestOptionFunc) (*Response, error)
	}

	// WorkItemsService handles communication with the work item related methods
@@ -204,6 +205,26 @@ var getWorkItemTemplate = template.Must(template.Must(workItemTemplate.Clone()).
	}
`))

const (
	getWorkItemIDQuery = `
			query GetWorkItemID($fullPath: ID!, $iid: String!) {
				namespace(fullPath: $fullPath) {
					workItem(iid: $iid) {
						  id
					}
				}
			}
	`

	deleteWorkItemQuery = `
			mutation DeleteWorkItem($id: WorkItemID!) {
					workItemDelete(input: { id: $id }) {
							errors
					}
			}
	`
)

// GetWorkItem gets a single work item.
//
// fullPath is the full path to either a group or project.
@@ -810,18 +831,6 @@ var updateWorkItemTemplate = template.Must(template.Must(workItemTemplate.Clone(
	}
`))

const (
	getWorkItemIDQuery = `
		query GetWorkItemID($fullPath: ID!, $iid: String!) {
			namespace(fullPath: $fullPath) {
				workItem(iid: $iid) {
					id
				}
			}
		}
	`
)

// createWorkItemTemplate is chained from workItemTemplate so it has access to both
// UserCoreBasic and WorkItem templates.
var createWorkItemTemplate = template.Must(template.Must(workItemTemplate.Clone()).New("CreateWorkItem").Parse(`
@@ -1129,6 +1138,54 @@ type workItemUpdateInputGQL struct {
	StatusWidget          *workItemWidgetStatusInputGQL                `json:"statusWidget,omitempty"`
}

// DeleteWorkItem deletes a single work item.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationworkitemdelete
func (s *WorkItemsService) DeleteWorkItem(fullPath string, iid int64, options ...RequestOptionFunc) (*Response, error) {
	// get the global ID
	gid, resp, err := s.workItemGID(fullPath, iid, options...)
	if err != nil {
		if errors.Is(err, ErrEmptyResponse) {
			return resp, ErrNotFound
		}
		return resp, err
	}

	mutation := GraphQLQuery{
		Query: deleteWorkItemQuery,
		Variables: map[string]any{
			"id": gid.String(),
		},
	}

	var result struct {
		Data struct {
			WorkItemDelete struct {
				Errors []string `json:"errors"`
			} `json:"workItemDelete"`
		}
		GenericGraphQLErrors
	}

	resp, err = s.client.GraphQL.Do(mutation, &result, options...)
	if err != nil {
		return resp, err
	}

	if len(result.Errors) != 0 {
		return resp, &GraphQLResponseError{
			Err:    errors.New("Mutation.workItemDelete failed"),
			Errors: result.GenericGraphQLErrors,
		}
	}

	if len(result.Data.WorkItemDelete.Errors) != 0 {
		return resp, errors.New(strings.Join(result.Data.WorkItemDelete.Errors, ", "))
	}

	return resp, nil
}

// workItemGID queries for a work item's Global ID using fullPath and iid.
// Returns the Global ID string or ErrNotFound if the work item doesn't exist.
//
@@ -1148,8 +1205,8 @@ func (s *WorkItemsService) workItemGID(fullPath string, iid int64, options ...Re
				WorkItem struct {
					ID gidGQL `json:"id"`
				} `json:"workItem"`
			}
		}
			} `json:"namespace"`
		} `json:"data"`
		GenericGraphQLErrors
	}

+145 −3
Original line number Diff line number Diff line
@@ -1250,6 +1250,147 @@ func TestUpdateWorkItem(t *testing.T) {
	}
}

func TestDeleteWorkItem(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		fullPath       string
		iid            int64
		getIDResponse  io.WriterTo
		deleteResponse io.WriterTo
		wantErr        error
	}{
		{
			name:     "successfully deletes work item",
			fullPath: "test-gitlab-org/gitlab",
			iid:      123,
			getIDResponse: strings.NewReader(`{
				"data": {
					"namespace": {
						"workItem": {
							"id": "gid://gitlab/WorkItem/183771442"
						}
					}
				}
			}`),
			deleteResponse: strings.NewReader(`{
				"data": {
					"workItemDelete": {
						"clientMutationId": null,
						"errors": [],
						"namespace": {
							"id": "gid://gitlab/Namespaces::ProjectNamespace/124736349"
						}
					}
				}
			}`),
			wantErr: nil,
		},
		{
			name:     "work item not found returns error",
			fullPath: "test-gitlab-org/gitlab",
			iid:      999,
			getIDResponse: strings.NewReader(`{
				 "data": {
					 "namespace": {
						 "workItem": null
					 }
				 }
			 }`),
			deleteResponse: nil,
			wantErr:        ErrNotFound,
		},
		{
			name:     "namespace not found returns error",
			fullPath: "does/not/exist",
			iid:      123,
			getIDResponse: strings.NewReader(`{
				 "data": {
					 "namespace": null
				 }
			 }`),
			deleteResponse: nil,
			wantErr:        ErrNotFound,
		},
		{
			name:     "delete mutation returns errors",
			fullPath: "test-gitlab-org/gitlab",
			iid:      123,
			getIDResponse: strings.NewReader(`{
				 "data": {
					 "namespace": {
						 "workItem": {
							 "id": "gid://gitlab/WorkItem/123"
						 }
					 }
				 }
			 }`),
			deleteResponse: strings.NewReader(`{
				 "data": {
					 "workItemDelete": {
						 "clientMutationId": null,
						 "errors": ["Work item cannot be deleted"],
						 "namespace": null
					 }
				 }
			 }`),
			wantErr: errors.New("Work item cannot be deleted"),
		},
	}

	schema := loadSchema(t)

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			mux, client := setup(t)

			mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
				defer r.Body.Close()

				testMethod(t, r, http.MethodPost)

				var q GraphQLQuery
				if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
					http.Error(w, err.Error(), http.StatusBadRequest)
					return
				}

				if err := validateSchema(schema, q); err != nil {
					http.Error(w, err.Error(), http.StatusBadRequest)
					return
				}

				w.Header().Set("Content-Type", "application/json")

				switch {
				case strings.Contains(q.Query, "GetWorkItemID"):
					tt.getIDResponse.WriteTo(w)
				case strings.Contains(q.Query, "DeleteWorkItem"):
					if tt.deleteResponse == nil {
						t.Errorf("unexpected DeleteWorkItem request: deleteResponse is nil")
						http.Error(w, "unexpected request", http.StatusInternalServerError)
						return
					}
					tt.deleteResponse.WriteTo(w)
				default:
					t.Errorf("unexpected query: %s", q.Query)
					http.Error(w, "unexpected query", http.StatusBadRequest)
				}
			})

			_, err := client.WorkItems.DeleteWorkItem(tt.fullPath, tt.iid)

			if tt.wantErr != nil {
				require.ErrorContains(t, err, tt.wantErr.Error())
				return
			}
			require.NoError(t, err)
		})
	}
}

func loadSchema(t *testing.T) *graphql.Schema {
	t.Helper()

@@ -1258,8 +1399,9 @@ func loadSchema(t *testing.T) *graphql.Schema {
	fh, err := os.Open(filename)
	switch {
	case errors.Is(err, os.ErrNotExist):
		t.Logf("GraphQL schema file %q is not available", filename)
		return nil
		t.Skipf("GraphQL schema file %q is not available; generate it with: "+
			"npm install -g get-graphql-schema && "+
			"get-graphql-schema https://gitlab.com/api/graphql --sdl > schema/gitlab.graphql", filename)

	case err != nil:
		t.Fatalf("opening schema failed: %v", err)
@@ -1272,7 +1414,7 @@ func loadSchema(t *testing.T) *graphql.Schema {

	schema, err := graphql.ParseSchema(string(data), nil)
	if err != nil {
		t.Fatalf("parsing schema failed: %v", err)
		t.Fatalf("parsing schema %q failed (schema file may be corrupt): %v", filename, err)
	}

	return schema