Verified Commit 07ee38c5 authored by Florian Forster's avatar Florian Forster
Browse files

feat(workitems): Implement `CreateWorkItem()`.

Initial implementation by GitLab Duo.
parent 2a32493f
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -76,6 +76,7 @@ const (
var (
	// ErrNotFound is returned for 404 Not Found errors
	ErrNotFound             = errors.New("404 Not Found")
	ErrWorkItemCreateFailed = errors.New("work item creation failed")

	// errUnauthenticated is an internal sentinel error to indicate that the auth source doesn't use any authentication
	errUnauthenticated = errors.New("unauthenticated")
+45 −0
Original line number Diff line number Diff line
@@ -39,6 +39,51 @@ func (m *MockWorkItemsServiceInterface) EXPECT() *MockWorkItemsServiceInterfaceM
	return m.recorder
}

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

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

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

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

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

// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockWorkItemsServiceInterfaceCreateWorkItemCall) DoAndReturn(f func(string, *gitlab.CreateWorkItemOptions, ...gitlab.RequestOptionFunc) (*gitlab.WorkItem, *gitlab.Response, error)) *MockWorkItemsServiceInterfaceCreateWorkItemCall {
	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()
+211 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ import (

type (
	WorkItemsServiceInterface interface {
		CreateWorkItem(fullPath string, opt *CreateWorkItemOptions, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		GetWorkItem(fullPath string, iid int64, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		ListWorkItems(fullPath string, opt *ListWorkItemsOptions, options ...RequestOptionFunc) ([]*WorkItem, *Response, error)
	}
@@ -500,6 +501,216 @@ func (s *WorkItemsService) ListWorkItems(fullPath string, opt *ListWorkItemsOpti
	return ret, resp, nil
}

// CreateWorkItemOptions represents the available CreateWorkItem() options.
//
// GitLab API docs:
// https://docs.gitlab.com/ee/api/graphql/reference/#workitemcreateinput
type CreateWorkItemOptions struct {
	AssigneesWidget       *WorkItemWidgetAssigneesInput         `gql:"assigneesWidget WorkItemWidgetAssigneesInput"`
	Confidential          *bool                                 `gql:"confidential Boolean"`
	DescriptionWidget     *WorkItemWidgetDescriptionInput       `gql:"descriptionWidget WorkItemWidgetDescriptionInput"`
	MilestoneWidget       *WorkItemWidgetMilestoneInput         `gql:"milestoneWidget WorkItemWidgetMilestoneInput"`
	CreateSource          *string                               `gql:"createSource String"`
	CreatedAt             *time.Time                            `gql:"createdAt Time"`
	CRMContactsWidget     *WorkItemWidgetCRMContactsCreateInput `gql:"crmContactsWidget WorkItemWidgetCrmContactsCreateInput"`
	HierarchyWidget       *WorkItemWidgetHierarchyCreateInput   `gql:"hierarchyWidget WorkItemWidgetHierarchyCreateInput"`
	LabelsWidget          *WorkItemWidgetLabelsCreateInput      `gql:"labelsWidget WorkItemWidgetLabelsCreateInput"`
	LinkedItemsWidget     *WorkItemWidgetLinkedItemsCreateInput `gql:"linkedItemsWidget WorkItemWidgetLinkedItemsCreateInput"`
	StartAndDueDateWidget *WorkItemWidgetStartAndDueDateInput   `gql:"startAndDueDateWidget WorkItemWidgetStartAndDueDateUpdateInput"`
	Title                 *string                               `gql:"title String!"`
	WorkItemTypeID        *string                               `gql:"workItemTypeId WorkItemsTypeID!"`
	WeightWidget          *WorkItemWidgetWeightInput            `gql:"weightWidget WorkItemWidgetWeightInput"`
	HealthStatusWidget    *WorkItemWidgetHealthStatusInput      `gql:"healthStatusWidget WorkItemWidgetHealthStatusInput"`
	IterationWidget       *WorkItemWidgetIterationInput         `gql:"iterationWidget WorkItemWidgetIterationInput"`
	ColorWidget           *WorkItemWidgetColorInput             `gql:"colorWidget WorkItemWidgetColorInput"`
}

// WorkItemWidgetAssigneesInput represents the assignees widget input.
type WorkItemWidgetAssigneesInput struct {
	AssigneeIDs []string `json:"assigneeIds,omitempty"`
}

// WorkItemWidgetDescriptionInput represents the description widget input.
type WorkItemWidgetDescriptionInput struct {
	Description *string `json:"description,omitempty"`
}

// WorkItemWidgetMilestoneInput represents the milestone widget input.
type WorkItemWidgetMilestoneInput struct {
	MilestoneID *string `json:"milestoneId,omitempty"`
}

// WorkItemWidgetCRMContactsCreateInput represents the CRM contacts widget input.
type WorkItemWidgetCRMContactsCreateInput struct {
	ContactIDs []string `json:"contactIds,omitempty"`
}

// WorkItemWidgetHierarchyCreateInput represents the hierarchy widget input.
type WorkItemWidgetHierarchyCreateInput struct {
	ParentID *string `json:"parentId,omitempty"`
}

// WorkItemWidgetLabelsCreateInput represents the labels widget input.
type WorkItemWidgetLabelsCreateInput struct {
	LabelIDs []string `json:"labelIds,omitempty"`
}

// WorkItemWidgetLinkedItemsCreateInput represents the linked items widget input.
type WorkItemWidgetLinkedItemsCreateInput struct {
	WorkItemIDs []string `json:"workItemIds,omitempty"`
}

// WorkItemWidgetStartAndDueDateInput represents the start and due date widget input.
type WorkItemWidgetStartAndDueDateInput struct {
	StartDate *string `json:"startDate,omitempty"`
	DueDate   *string `json:"dueDate,omitempty"`
}

// WorkItemWidgetWeightInput represents the weight widget input.
type WorkItemWidgetWeightInput struct {
	Weight *int64 `json:"weight,omitempty"`
}

// WorkItemWidgetHealthStatusInput represents the health status widget input.
type WorkItemWidgetHealthStatusInput struct {
	HealthStatus *string `json:"healthStatus,omitempty"`
}

// WorkItemWidgetIterationInput represents the iteration widget input.
type WorkItemWidgetIterationInput struct {
	IterationID *string `json:"iterationId,omitempty"`
}

// WorkItemWidgetColorInput represents the color widget input.
type WorkItemWidgetColorInput struct {
	Color *string `json:"color,omitempty"`
}

// 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(`
	mutation CreateWorkItem($input: WorkItemCreateInput!) {
	  workItemCreate(input: $input) {
	    workItem {
	      {{ template "WorkItem" }}
	    }
	    errors
	  }
	}
`))

// CreateWorkItem creates a new work item.
//
// fullPath is the full path to either a group or project.
// opt contains the options for creating the work item.
//
// GitLab API docs:
// https://docs.gitlab.com/ee/api/graphql/reference/#workitemcreateinput
func (s *WorkItemsService) CreateWorkItem(fullPath string, opt *CreateWorkItemOptions, options ...RequestOptionFunc) (*WorkItem, *Response, error) {
	var queryBuilder strings.Builder
	if err := createWorkItemTemplate.Execute(&queryBuilder, nil); err != nil {
		return nil, nil, err
	}

	input := map[string]any{
		"namespacePath": fullPath,
	}

	if opt != nil {
		if opt.Title != nil {
			input["title"] = *opt.Title
		}
		if opt.WorkItemTypeID != nil {
			input["workItemTypeId"] = *opt.WorkItemTypeID
		}
		if opt.Confidential != nil {
			input["confidential"] = *opt.Confidential
		}
		if opt.DescriptionWidget != nil {
			input["descriptionWidget"] = opt.DescriptionWidget
		}
		if opt.AssigneesWidget != nil {
			input["assigneesWidget"] = opt.AssigneesWidget
		}
		if opt.MilestoneWidget != nil {
			input["milestoneWidget"] = opt.MilestoneWidget
		}
		if opt.CreateSource != nil {
			input["createSource"] = *opt.CreateSource
		}
		if opt.CreatedAt != nil {
			input["createdAt"] = opt.CreatedAt
		}
		if opt.CRMContactsWidget != nil {
			input["crmContactsWidget"] = opt.CRMContactsWidget
		}
		if opt.HierarchyWidget != nil {
			input["hierarchyWidget"] = opt.HierarchyWidget
		}
		if opt.LabelsWidget != nil {
			input["labelsWidget"] = opt.LabelsWidget
		}
		if opt.LinkedItemsWidget != nil {
			input["linkedItemsWidget"] = opt.LinkedItemsWidget
		}
		if opt.StartAndDueDateWidget != nil {
			input["startAndDueDateWidget"] = opt.StartAndDueDateWidget
		}
		if opt.WeightWidget != nil {
			input["weightWidget"] = opt.WeightWidget
		}
		if opt.HealthStatusWidget != nil {
			input["healthStatusWidget"] = opt.HealthStatusWidget
		}
		if opt.IterationWidget != nil {
			input["iterationWidget"] = opt.IterationWidget
		}
		if opt.ColorWidget != nil {
			input["colorWidget"] = opt.ColorWidget
		}
	}

	q := GraphQLQuery{
		Query: queryBuilder.String(),
		Variables: map[string]any{
			"input": input,
		},
	}

	var result struct {
		Data struct {
			WorkItemCreate struct {
				WorkItem *workItemGQL `json:"workItem"`
				Errors   []string     `json:"errors"`
			} `json:"workItemCreate"`
		}
		GenericGraphQLErrors
	}

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

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

	if len(result.Data.WorkItemCreate.Errors) != 0 {
		return nil, resp, ErrWorkItemCreateFailed
	}

	wiQL := result.Data.WorkItemCreate.WorkItem
	if wiQL == nil {
		return nil, resp, ErrWorkItemCreateFailed
	}

	return wiQL.unwrap(), resp, nil
}

// workItemGQL represents the JSON structure returned by the GraphQL query.
// It is used to parse the response and convert it to the more user-friendly WorkItem type.
type workItemGQL struct {
+232 −0
Original line number Diff line number Diff line
@@ -583,6 +583,238 @@ func TestListWorkItems_Pagination(t *testing.T) {
	assert.Equal(t, want, got)
}

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

	tests := []struct {
		name     string
		fullPath string
		opt      *CreateWorkItemOptions
		response io.WriterTo
		want     *WorkItem
		wantErr  error
	}{
		{
			name:     "successful creation with title only",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &CreateWorkItemOptions{
				Title:          Ptr("New Task"),
				WorkItemTypeID: Ptr("gid://gitlab/WorkItems::Type/1"),
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "workItemCreate": {
				      "workItem": {
				        "id": "gid://gitlab/WorkItem/181297786",
				        "iid": "40",
				        "workItemType": {
				          "name": "Task"
				        },
				        "state": "OPEN",
				        "title": "New Task",
				        "description": "",
				        "author": {
				          "id": "gid://gitlab/User/5532616",
				          "username": "fforster",
				          "name": "Florian Forster",
				          "state": "active",
				          "locked": false,
				          "createdAt": "2020-03-02T06:29:14Z",
				          "avatarUrl": "/uploads/-/system/user/avatar/5532616/avatar.png",
				          "webUrl": "https://gitlab.com/fforster"
				        },
				        "createdAt": "2026-02-06T10:00:00Z",
				        "updatedAt": "2026-02-06T10:00:00Z",
				        "closedAt": null,
				        "webUrl": "https://gitlab.com/gitlab-com/gl-infra/platform/runway/team/-/work_items/40",
				        "features": {
				          "assignees": {
				            "assignees": {
				              "nodes": []
				            }
				          },
				          "status": {
				            "status": {
				              "name": "New"
				            }
				          }
				        }
				      },
				      "errors": []
				    }
				  },
				  "correlationId": "9c88d56b0061dfef-IAD"
				}
			`),
			want: &WorkItem{
				ID:          181297786,
				IID:         40,
				Type:        "Task",
				State:       "OPEN",
				Status:      Ptr("New"),
				Title:       "New Task",
				Description: "",
				CreatedAt:   Ptr(time.Date(2026, time.February, 6, 10, 0, 0, 0, time.UTC)),
				UpdatedAt:   Ptr(time.Date(2026, time.February, 6, 10, 0, 0, 0, time.UTC)),
				WebURL:      "https://gitlab.com/gitlab-com/gl-infra/platform/runway/team/-/work_items/40",
				Author: &BasicUser{
					ID:        5532616,
					Username:  "fforster",
					Name:      "Florian Forster",
					State:     "active",
					CreatedAt: Ptr(time.Date(2020, time.March, 2, 6, 29, 14, 0, time.UTC)),
					AvatarURL: "/uploads/-/system/user/avatar/5532616/avatar.png",
					WebURL:    "https://gitlab.com/fforster",
				},
				Assignees: nil,
			},
		},
		{
			name:     "successful creation with description",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &CreateWorkItemOptions{
				Title:          Ptr("New Issue"),
				WorkItemTypeID: Ptr("gid://gitlab/WorkItems::Type/2"),
				DescriptionWidget: &WorkItemWidgetDescriptionInput{
					Description: Ptr("This is a detailed description"),
				},
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "workItemCreate": {
				      "workItem": {
				        "id": "gid://gitlab/WorkItem/181297787",
				        "iid": "41",
				        "workItemType": {
				          "name": "Issue"
				        },
				        "state": "OPEN",
				        "title": "New Issue",
				        "description": "This is a detailed description",
				        "author": {
				          "id": "gid://gitlab/User/5532616",
				          "username": "fforster",
				          "name": "Florian Forster",
				          "state": "active",
				          "locked": false,
				          "createdAt": "2020-03-02T06:29:14Z",
				          "avatarUrl": "/uploads/-/system/user/avatar/5532616/avatar.png",
				          "webUrl": "https://gitlab.com/fforster"
				        },
				        "createdAt": "2026-02-06T10:00:00Z",
				        "updatedAt": "2026-02-06T10:00:00Z",
				        "closedAt": null,
				        "webUrl": "https://gitlab.com/gitlab-com/gl-infra/platform/runway/team/-/work_items/41",
				        "features": {
				          "assignees": {
				            "assignees": {
				              "nodes": []
				            }
				          },
				          "status": {
				            "status": {
				              "name": "New"
				            }
				          }
				        }
				      },
				      "errors": []
				    }
				  },
				  "correlationId": "9c88d56b0061dfef-IAD"
				}
			`),
			want: &WorkItem{
				ID:          181297787,
				IID:         41,
				Type:        "Issue",
				State:       "OPEN",
				Status:      Ptr("New"),
				Title:       "New Issue",
				Description: "This is a detailed description",
				CreatedAt:   Ptr(time.Date(2026, time.February, 6, 10, 0, 0, 0, time.UTC)),
				UpdatedAt:   Ptr(time.Date(2026, time.February, 6, 10, 0, 0, 0, time.UTC)),
				WebURL:      "https://gitlab.com/gitlab-com/gl-infra/platform/runway/team/-/work_items/41",
				Author: &BasicUser{
					ID:        5532616,
					Username:  "fforster",
					Name:      "Florian Forster",
					State:     "active",
					CreatedAt: Ptr(time.Date(2020, time.March, 2, 6, 29, 14, 0, time.UTC)),
					AvatarURL: "/uploads/-/system/user/avatar/5532616/avatar.png",
					WebURL:    "https://gitlab.com/fforster",
				},
				Assignees: nil,
			},
		},
		{
			name:     "creation with errors",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &CreateWorkItemOptions{
				Title:          Ptr(""),
				WorkItemTypeID: Ptr("gid://gitlab/WorkItems::Type/1"),
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "workItemCreate": {
				      "workItem": null,
				      "errors": ["Title can't be blank"]
				    }
				  },
				  "correlationId": "9c88d56b0061dfef-IAD"
				}
			`),
			want:    nil,
			wantErr: ErrWorkItemCreateFailed,
		},
	}

	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")
				tt.response.WriteTo(w)
			})

			got, _, err := client.WorkItems.CreateWorkItem(tt.fullPath, tt.opt)

			if tt.wantErr != nil {
				require.ErrorIs(t, err, tt.wantErr)
				assert.Nil(t, got)

				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.want, got)
		})
	}
}

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