Loading gitlab.go +2 −1 Original line number Diff line number Diff line Loading @@ -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") Loading testing/workitems_mock.go +45 −0 Original line number Diff line number Diff line Loading @@ -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() Loading workitems.go +211 −0 Original line number Diff line number Diff line Loading @@ -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) } Loading Loading @@ -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 { Loading workitems_test.go +232 −0 Original line number Diff line number Diff line Loading @@ -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() Loading Loading
gitlab.go +2 −1 Original line number Diff line number Diff line Loading @@ -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") Loading
testing/workitems_mock.go +45 −0 Original line number Diff line number Diff line Loading @@ -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() Loading
workitems.go +211 −0 Original line number Diff line number Diff line Loading @@ -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) } Loading Loading @@ -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 { Loading
workitems_test.go +232 −0 Original line number Diff line number Diff line Loading @@ -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() Loading