diff --git a/github/event_types.go b/github/event_types.go index 4e8dc55bd10..178df686f2f 100644 --- a/github/event_types.go +++ b/github/event_types.go @@ -1148,20 +1148,42 @@ type FieldValue struct { To json.RawMessage `json:"to,omitempty"` } +// ProjectV2ItemFieldValue represents a field value of a project item. +type ProjectV2ItemFieldValue struct { + ID *int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + // Value set for the field. The type depends on the field type: + // - text: string + // - number: float64 + // - date: string (ISO 8601 date format, e.g. "2023-06-23") or null + // - single_select: object with "id", "name", "color", "description" fields or null + // - iteration: object with "id", "title", "start_date", "duration" fields or null + // - title: object with "text" field (read-only, reflects the item's title) or null + // - assignees: array of user objects with "login", "id", etc. or null + // - labels: array of label objects with "id", "name", "color", etc. or null + // - linked_pull_requests: array of pull request objects or null + // - milestone: milestone object with "id", "title", "description", etc. or null + // - repository: repository object with "id", "name", "full_name", etc. or null + // - reviewers: array of user objects or null + // - status: object with "id", "name", "color", "description" fields (same structure as single_select) or null + Value any `json:"value,omitempty"` +} + // ProjectV2Item represents an item belonging to a project. type ProjectV2Item struct { - ID *int64 `json:"id,omitempty"` - NodeID *string `json:"node_id,omitempty"` - ProjectNodeID *string `json:"project_node_id,omitempty"` - ContentNodeID *string `json:"content_node_id,omitempty"` - ProjectURL *string `json:"project_url,omitempty"` - ContentType *string `json:"content_type,omitempty"` - Creator *User `json:"creator,omitempty"` - CreatedAt *Timestamp `json:"created_at,omitempty"` - UpdatedAt *Timestamp `json:"updated_at,omitempty"` - ArchivedAt *Timestamp `json:"archived_at,omitempty"` - ItemURL *string `json:"item_url,omitempty"` - Fields []*ProjectV2Field `json:"fields,omitempty"` + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectNodeID *string `json:"project_node_id,omitempty"` + ContentNodeID *string `json:"content_node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + ContentType *string `json:"content_type,omitempty"` + Creator *User `json:"creator,omitempty"` + CreatedAt *Timestamp `json:"created_at,omitempty"` + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + ArchivedAt *Timestamp `json:"archived_at,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + Fields []*ProjectV2ItemFieldValue `json:"fields,omitempty"` } // PublicEvent is triggered when a private repository is open sourced. diff --git a/github/github-accessors.go b/github/github-accessors.go index 688f924090c..bba6f7a169a 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -19326,12 +19326,12 @@ func (p *ProjectV2FieldIteration) GetStartDate() string { return *p.StartDate } -// GetTitle returns the Title field if it's non-nil, zero value otherwise. -func (p *ProjectV2FieldIteration) GetTitle() string { - if p == nil || p.Title == nil { - return "" +// GetTitle returns the Title field. +func (p *ProjectV2FieldIteration) GetTitle() *ProjectV2TextContent { + if p == nil { + return nil } - return *p.Title + return p.Title } // GetColor returns the Color field if it's non-nil, zero value otherwise. @@ -19342,12 +19342,12 @@ func (p *ProjectV2FieldOption) GetColor() string { return *p.Color } -// GetDescription returns the Description field if it's non-nil, zero value otherwise. -func (p *ProjectV2FieldOption) GetDescription() string { - if p == nil || p.Description == nil { - return "" +// GetDescription returns the Description field. +func (p *ProjectV2FieldOption) GetDescription() *ProjectV2TextContent { + if p == nil { + return nil } - return *p.Description + return p.Description } // GetID returns the ID field if it's non-nil, zero value otherwise. @@ -19358,12 +19358,12 @@ func (p *ProjectV2FieldOption) GetID() string { return *p.ID } -// GetName returns the Name field if it's non-nil, zero value otherwise. -func (p *ProjectV2FieldOption) GetName() string { - if p == nil || p.Name == nil { - return "" +// GetName returns the Name field. +func (p *ProjectV2FieldOption) GetName() *ProjectV2TextContent { + if p == nil { + return nil } - return *p.Name + return p.Name } // GetArchivedAt returns the ArchivedAt field if it's non-nil, zero value otherwise. @@ -19518,6 +19518,30 @@ func (p *ProjectV2ItemEvent) GetSender() *User { return p.Sender } +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (p *ProjectV2ItemFieldValue) GetID() int64 { + if p == nil || p.ID == nil { + return 0 + } + return *p.ID +} + +// GetHTML returns the HTML field if it's non-nil, zero value otherwise. +func (p *ProjectV2TextContent) GetHTML() string { + if p == nil || p.HTML == nil { + return "" + } + return *p.HTML +} + +// GetRaw returns the Raw field if it's non-nil, zero value otherwise. +func (p *ProjectV2TextContent) GetRaw() string { + if p == nil || p.Raw == nil { + return "" + } + return *p.Raw +} + // GetAllowDeletions returns the AllowDeletions field. func (p *Protection) GetAllowDeletions() *AllowDeletions { if p == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index b1c46d5a26c..69d58790f66 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -25110,10 +25110,7 @@ func TestProjectV2FieldIteration_GetStartDate(tt *testing.T) { func TestProjectV2FieldIteration_GetTitle(tt *testing.T) { tt.Parallel() - var zeroValue string - p := &ProjectV2FieldIteration{Title: &zeroValue} - p.GetTitle() - p = &ProjectV2FieldIteration{} + p := &ProjectV2FieldIteration{} p.GetTitle() p = nil p.GetTitle() @@ -25132,10 +25129,7 @@ func TestProjectV2FieldOption_GetColor(tt *testing.T) { func TestProjectV2FieldOption_GetDescription(tt *testing.T) { tt.Parallel() - var zeroValue string - p := &ProjectV2FieldOption{Description: &zeroValue} - p.GetDescription() - p = &ProjectV2FieldOption{} + p := &ProjectV2FieldOption{} p.GetDescription() p = nil p.GetDescription() @@ -25154,10 +25148,7 @@ func TestProjectV2FieldOption_GetID(tt *testing.T) { func TestProjectV2FieldOption_GetName(tt *testing.T) { tt.Parallel() - var zeroValue string - p := &ProjectV2FieldOption{Name: &zeroValue} - p.GetName() - p = &ProjectV2FieldOption{} + p := &ProjectV2FieldOption{} p.GetName() p = nil p.GetName() @@ -25348,6 +25339,39 @@ func TestProjectV2ItemEvent_GetSender(tt *testing.T) { p.GetSender() } +func TestProjectV2ItemFieldValue_GetID(tt *testing.T) { + tt.Parallel() + var zeroValue int64 + p := &ProjectV2ItemFieldValue{ID: &zeroValue} + p.GetID() + p = &ProjectV2ItemFieldValue{} + p.GetID() + p = nil + p.GetID() +} + +func TestProjectV2TextContent_GetHTML(tt *testing.T) { + tt.Parallel() + var zeroValue string + p := &ProjectV2TextContent{HTML: &zeroValue} + p.GetHTML() + p = &ProjectV2TextContent{} + p.GetHTML() + p = nil + p.GetHTML() +} + +func TestProjectV2TextContent_GetRaw(tt *testing.T) { + tt.Parallel() + var zeroValue string + p := &ProjectV2TextContent{Raw: &zeroValue} + p.GetRaw() + p = &ProjectV2TextContent{} + p.GetRaw() + p = nil + p.GetRaw() +} + func TestProtection_GetAllowDeletions(tt *testing.T) { tt.Parallel() p := &Protection{} diff --git a/github/projects.go b/github/projects.go index 1ccadaeaae5..af602bc5657 100644 --- a/github/projects.go +++ b/github/projects.go @@ -74,15 +74,24 @@ type ListProjectsOptions struct { Query *string `url:"q,omitempty"` } +// ProjectV2TextContent represents text content in a project field option or iteration. +// It includes both HTML and raw text representations. +// +// GitHub API docs: https://docs.github.com/rest/projects/fields +type ProjectV2TextContent struct { + HTML *string `json:"html,omitempty"` + Raw *string `json:"raw,omitempty"` +} + // ProjectV2FieldOption represents an option for a project field of type single_select or multi_select. // It defines the available choices that can be selected for dropdown-style fields. // // GitHub API docs: https://docs.github.com/rest/projects/fields type ProjectV2FieldOption struct { - ID *string `json:"id,omitempty"` // The unique identifier for this option. - Name *string `json:"name,omitempty"` // The display name of the option. - Color *string `json:"color,omitempty"` // The color associated with this option (e.g., "blue", "red"). - Description *string `json:"description,omitempty"` // An optional description for this option. + ID *string `json:"id,omitempty"` // The unique identifier for this option. + Color *string `json:"color,omitempty"` // The color associated with this option (e.g., "blue", "red"). + Description *ProjectV2TextContent `json:"description,omitempty"` // An optional description for this option. + Name *ProjectV2TextContent `json:"name,omitempty"` // The display name of the option. } // ProjectV2FieldIteration represents an iteration within a project field of type iteration. @@ -90,10 +99,10 @@ type ProjectV2FieldOption struct { // // GitHub API docs: https://docs.github.com/rest/projects/fields type ProjectV2FieldIteration struct { - ID *string `json:"id,omitempty"` // The unique identifier for the iteration. - Title *string `json:"title,omitempty"` // The title of the iteration. - StartDate *string `json:"start_date,omitempty"` // The start date of the iteration in ISO 8601 format. - Duration *int `json:"duration,omitempty"` // The duration of the iteration in seconds. + ID *string `json:"id,omitempty"` // The unique identifier for the iteration. + Title *ProjectV2TextContent `json:"title,omitempty"` // The title of the iteration. + StartDate *string `json:"start_date,omitempty"` // The start date of the iteration in ISO 8601 format. + Duration *int `json:"duration,omitempty"` // The duration of the iteration in seconds. } // ProjectV2FieldConfiguration represents the configuration for a project field of type iteration. @@ -325,14 +334,31 @@ type AddProjectItemOptions struct { ID int64 `json:"id,omitempty"` } +// UpdateProjectV2Field represents a field update for a project item. +// +// GitHub API docs: https://docs.github.com/rest/projects/items#update-project-item-for-organization +type UpdateProjectV2Field struct { + // ID is the field ID to update. + ID int64 `json:"id"` + // Value is the new value to set for the field. The type depends on the field type. + // For text fields: string + // For number fields: float64 or int + // For single_select fields: string (option ID) + // For date fields: string (ISO 8601 date) + // For iteration fields: string (iteration ID) + // Note: Some field types (title, assignees, labels, etc.) are read-only or managed through other API endpoints. + Value any `json:"value"` +} + // UpdateProjectItemOptions represents fields that can be modified for a project item. -// Currently the REST API allows archiving/unarchiving an item (archived boolean). -// This struct can be expanded in the future as the API grows. +// The GitHub API expects either archived status updates or field value updates. type UpdateProjectItemOptions struct { // Archived indicates whether the item should be archived (true) or unarchived (false). + // This is used for archive/unarchive operations. Archived *bool `json:"archived,omitempty"` - // Fields allows updating field values for the item. Each entry supplies a field ID and a value. - Fields []*ProjectV2Field `json:"fields,omitempty"` + // Fields contains field updates to apply to the project item. + // Each entry specifies a field ID and its new value. + Fields []*UpdateProjectV2Field `json:"fields,omitempty"` } // ListOrganizationProjectItems lists items for an organization owned project. @@ -387,7 +413,11 @@ func (s *ProjectsService) AddOrganizationProjectItem(ctx context.Context, org st //meta:operation GET /orgs/{org}/projectsV2/{project_number}/items/{item_id} func (s *ProjectsService) GetOrganizationProjectItem(ctx context.Context, org string, projectNumber int, itemID int64, opts *GetProjectItemOptions) (*ProjectV2Item, *Response, error) { u := fmt.Sprintf("orgs/%v/projectsV2/%v/items/%v", org, projectNumber, itemID) - req, err := s.client.NewRequest("GET", u, opts) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err } @@ -481,7 +511,11 @@ func (s *ProjectsService) AddUserProjectItem(ctx context.Context, username strin //meta:operation GET /users/{username}/projectsV2/{project_number}/items/{item_id} func (s *ProjectsService) GetUserProjectItem(ctx context.Context, username string, projectNumber int, itemID int64, opts *GetProjectItemOptions) (*ProjectV2Item, *Response, error) { u := fmt.Sprintf("users/%v/projectsV2/%v/items/%v", username, projectNumber, itemID) - req, err := s.client.NewRequest("GET", u, opts) + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err } diff --git a/github/projects_test.go b/github/projects_test.go index 4bb656b9e4b..4b30b3d4ecb 100644 --- a/github/projects_test.go +++ b/github/projects_test.go @@ -185,8 +185,8 @@ func TestProjectsService_ListOrganizationProjectFields(t *testing.T) { "data_type": "single_select", "url": "https://api.github.com/projects/1/fields/field1", "options": [ - {"id": "1", "name": "Todo", "color": "blue", "description": "Tasks to be done"}, - {"id": "2", "name": "In Progress", "color": "yellow"} + {"id": "1", "name": {"raw": "Todo", "html": "Todo"}, "color": "blue", "description": {"raw": "Tasks to be done", "html": "Tasks to be done"}}, + {"id": "2", "name": {"raw": "In Progress", "html": "In Progress"}, "color": "yellow"} ], "created_at": "2011-01-02T15:04:05Z", "updated_at": "2012-01-02T15:04:05Z" @@ -254,8 +254,8 @@ func TestProjectsService_ListUserProjectFields(t *testing.T) { "data_type": "single_select", "url": "https://api.github.com/projects/1/fields/field1", "options": [ - {"id": "1", "name": "Todo", "color": "blue", "description": "Tasks to be done"}, - {"id": "2", "name": "In Progress", "color": "yellow"} + {"id": "1", "name": {"raw": "Todo", "html": "Todo"}, "color": "blue", "description": {"raw": "Tasks to be done", "html": "Tasks to be done"}}, + {"id": "2", "name": {"raw": "In Progress", "html": "In Progress"}, "color": "yellow"} ], "created_at": "2011-01-02T15:04:05Z", "updated_at": "2012-01-02T15:04:05Z" @@ -317,8 +317,8 @@ func TestProjectsService_GetOrganizationProjectField(t *testing.T) { "data_type": "single_select", "url": "https://api.github.com/projects/1/fields/field1", "options": [ - {"id": "1", "name": "Todo", "color": "blue", "description": "Tasks to be done"}, - {"id": "2", "name": "In Progress", "color": "yellow"} + {"id": "1", "name": {"raw": "Todo", "html": "Todo"}, "color": "blue", "description": {"raw": "Tasks to be done", "html": "Tasks to be done"}}, + {"id": "2", "name": {"raw": "In Progress", "html": "In Progress"}, "color": "yellow"} ], "created_at": "2011-01-02T15:04:05Z", "updated_at": "2012-01-02T15:04:05Z" @@ -358,8 +358,8 @@ func TestProjectsService_GetUserProjectField(t *testing.T) { "data_type": "single_select", "url": "https://api.github.com/projects/1/fields/field3", "options": [ - {"id": "1", "name": "Done", "color": "red", "description": "Done task"}, - {"id": "2", "name": "In Progress", "color": "yellow"} + {"id": "1", "name": {"raw": "Done", "html": "Done"}, "color": "red", "description": {"raw": "Done task", "html": "Done task"}}, + {"id": "2", "name": {"raw": "In Progress", "html": "In Progress"}, "color": "yellow"} ], "created_at": "2011-01-02T15:04:05Z", "updated_at": "2012-01-02T15:04:05Z" @@ -589,9 +589,9 @@ func TestProjectV2Field_Marshal(t *testing.T) { Options: []*ProjectV2FieldOption{ { ID: Ptr("1"), - Name: Ptr("Todo"), + Name: &ProjectV2TextContent{Raw: Ptr("Todo"), HTML: Ptr("Todo")}, Color: Ptr("blue"), - Description: Ptr("Tasks to be done"), + Description: &ProjectV2TextContent{Raw: Ptr("Tasks to be done"), HTML: Ptr("Tasks to be done")}, }, }, CreatedAt: &Timestamp{referenceTime}, @@ -607,9 +607,15 @@ func TestProjectV2Field_Marshal(t *testing.T) { "options": [ { "id": "1", - "name": "Todo", "color": "blue", - "description": "Tasks to be done" + "description": { + "raw": "Tasks to be done", + "html": "Tasks to be done" + }, + "name": { + "raw": "Todo", + "html": "Todo" + } } ], "created_at": ` + referenceTimeStr + `, @@ -638,13 +644,13 @@ func TestProjectV2FieldConfiguration_Marshal(t *testing.T) { Iterations: []*ProjectV2FieldIteration{ { ID: Ptr("iter_1"), - Title: Ptr("Sprint 1"), + Title: &ProjectV2TextContent{Raw: Ptr("Sprint 1"), HTML: Ptr("Sprint 1")}, StartDate: Ptr("2025-01-06"), Duration: Ptr(1209600), }, { ID: Ptr("iter_2"), - Title: Ptr("Sprint 2"), + Title: &ProjectV2TextContent{Raw: Ptr("Sprint 2"), HTML: Ptr("Sprint 2")}, StartDate: Ptr("2025-01-20"), Duration: Ptr(1209600), }, @@ -666,13 +672,19 @@ func TestProjectV2FieldConfiguration_Marshal(t *testing.T) { "iterations": [ { "id": "iter_1", - "title": "Sprint 1", + "title": { + "raw": "Sprint 1", + "html": "Sprint 1" + }, "start_date": "2025-01-06", "duration": 1209600 }, { "id": "iter_2", - "title": "Sprint 2", + "title": { + "raw": "Sprint 2", + "html": "Sprint 2" + }, "start_date": "2025-01-20", "duration": 1209600 } @@ -691,7 +703,7 @@ func TestProjectV2FieldConfiguration_Marshal(t *testing.T) { Iterations: []*ProjectV2FieldIteration{ { ID: Ptr("config_iter_1"), - Title: Ptr("Week 1"), + Title: &ProjectV2TextContent{Raw: Ptr("Week 1"), HTML: Ptr("Week 1")}, StartDate: Ptr("2025-01-01"), Duration: Ptr(604800), }, @@ -704,7 +716,10 @@ func TestProjectV2FieldConfiguration_Marshal(t *testing.T) { "iterations": [ { "id": "config_iter_1", - "title": "Week 1", + "title": { + "raw": "Week 1", + "html": "Week 1" + }, "start_date": "2025-01-01", "duration": 604800 } @@ -716,14 +731,17 @@ func TestProjectV2FieldConfiguration_Marshal(t *testing.T) { // Test iteration struct by itself iteration := &ProjectV2FieldIteration{ ID: Ptr("single_iter"), - Title: Ptr("Test Iteration"), + Title: &ProjectV2TextContent{Raw: Ptr("Test Iteration"), HTML: Ptr("Test Iteration")}, StartDate: Ptr("2025-02-01"), Duration: Ptr(1209600), } iterationWant := `{ "id": "single_iter", - "title": "Test Iteration", + "title": { + "raw": "Test Iteration", + "html": "Test Iteration" + }, "start_date": "2025-02-01", "duration": 1209600 }` @@ -855,6 +873,48 @@ func TestProjectsService_GetOrganizationProjectItem_error(t *testing.T) { }) } +func TestProjectsService_GetOrganizationProjectItem_WithFieldsOption(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/orgs/o/projectsV2/1/items/17", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + // Verify that fields option is properly added as comma-separated URL parameter + testFormValues(t, r, values{"fields": "123,456,789"}) + fmt.Fprint(w, `{ + "id":17, + "node_id":"PVTI_node_fields", + "fields":[ + {"id":123,"name":"Status","data_type":"single_select"}, + {"id":456,"name":"Priority","data_type":"single_select"}, + {"id":789,"name":"Assignee","data_type":"text"} + ] + }`) + }) + ctx := t.Context() + opts := &GetProjectItemOptions{ + Fields: []int64{123, 456, 789}, // Request specific field IDs + } + item, _, err := client.Projects.GetOrganizationProjectItem(ctx, "o", 1, 17, opts) + if err != nil { + t.Fatalf("GetOrganizationProjectItem error: %v", err) + } + if item.GetID() != 17 { + t.Fatalf("unexpected item: %+v", item) + } + const methodName = "GetOrganizationProjectItemWithFields" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.GetOrganizationProjectItem(ctx, "\n", 1, 17, opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.GetOrganizationProjectItem(ctx, "o", 1, 17, opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + func TestProjectsService_UpdateOrganizationProjectItem(t *testing.T) { t.Parallel() client, mux, _ := setup(t) @@ -897,6 +957,50 @@ func TestProjectsService_UpdateOrganizationProjectItem_error(t *testing.T) { }) } +func TestProjectsService_UpdateOrganizationProjectItem_WithFieldUpdates(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/orgs/o/projectsV2/1/items/17", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PATCH") + b, _ := io.ReadAll(r.Body) + body := string(b) + // Verify the field updates are properly formatted in the request body + expectedBody := `{"fields":[{"id":123,"value":"Updated text value"},{"id":456,"value":"Done"}]}` + if body != expectedBody+"\n" { + t.Fatalf("unexpected body: %s, expected: %s", body, expectedBody) + } + fmt.Fprint(w, `{"id":17,"node_id":"PVTI_node_updated"}`) + }) + + ctx := t.Context() + opts := &UpdateProjectItemOptions{ + Fields: []*UpdateProjectV2Field{ + {ID: 123, Value: "Updated text value"}, + {ID: 456, Value: "Done"}, + }, + } + item, _, err := client.Projects.UpdateOrganizationProjectItem(ctx, "o", 1, 17, opts) + if err != nil { + t.Fatalf("UpdateOrganizationProjectItem error: %v", err) + } + if item.GetID() != 17 { + t.Fatalf("unexpected item: %+v", item) + } + + const methodName = "UpdateOrganizationProjectItemWithFields" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.UpdateOrganizationProjectItem(ctx, "\n", 1, 17, opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.UpdateOrganizationProjectItem(ctx, "o", 1, 17, opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + func TestProjectsService_DeleteOrganizationProjectItem(t *testing.T) { t.Parallel() client, mux, _ := setup(t) @@ -1040,6 +1144,48 @@ func TestProjectsService_GetUserProjectItem_error(t *testing.T) { }) } +func TestProjectsService_GetUserProjectItem_WithFieldsOption(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/users/u/projectsV2/2/items/55", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + // Verify that fields option is properly added as comma-separated URL parameter + testFormValues(t, r, values{"fields": "100,200"}) + fmt.Fprint(w, `{ + "id":55, + "node_id":"PVTI_user_item_fields", + "fields":[ + {"id":100,"name":"Status","data_type":"single_select"}, + {"id":200,"name":"Milestone","data_type":"text"} + ] + }`) + }) + ctx := t.Context() + opts := &GetProjectItemOptions{ + Fields: []int64{100, 200}, // Request specific field IDs + } + item, _, err := client.Projects.GetUserProjectItem(ctx, "u", 2, 55, opts) + if err != nil { + t.Fatalf("GetUserProjectItem error: %v", err) + } + if item.GetID() != 55 { + t.Fatalf("unexpected item: %+v", item) + } + + const methodName = "GetUserProjectItemWithFields" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.GetUserProjectItem(ctx, "\n", 2, 55, opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.GetUserProjectItem(ctx, "u", 2, 55, opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + func TestProjectsService_UpdateUserProjectItem(t *testing.T) { t.Parallel() client, mux, _ := setup(t) @@ -1082,6 +1228,50 @@ func TestProjectsService_UpdateUserProjectItem_error(t *testing.T) { }) } +func TestProjectsService_UpdateUserProjectItem_WithFieldUpdates(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + mux.HandleFunc("/users/u/projectsV2/2/items/55", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PATCH") + b, _ := io.ReadAll(r.Body) + body := string(b) + // Verify the field updates are properly formatted in the request body + expectedBody := `{"fields":[{"id":100,"value":"In Progress"},{"id":200,"value":5}]}` + if body != expectedBody+"\n" { + t.Fatalf("unexpected body: %s, expected: %s", body, expectedBody) + } + fmt.Fprint(w, `{"id":55,"node_id":"PVTI_user_updated"}`) + }) + + ctx := t.Context() + opts := &UpdateProjectItemOptions{ + Fields: []*UpdateProjectV2Field{ + {ID: 100, Value: "In Progress"}, + {ID: 200, Value: 5}, // number field + }, + } + item, _, err := client.Projects.UpdateUserProjectItem(ctx, "u", 2, 55, opts) + if err != nil { + t.Fatalf("UpdateUserProjectItem error: %v", err) + } + if item.GetID() != 55 { + t.Fatalf("unexpected item: %+v", item) + } + + const methodName = "UpdateUserProjectItemWithFields" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.UpdateUserProjectItem(ctx, "\n", 2, 55, opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.UpdateUserProjectItem(ctx, "u", 2, 55, opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + func TestProjectsService_DeleteUserProjectItem(t *testing.T) { t.Parallel() client, mux, _ := setup(t)