Skip to content
37 changes: 31 additions & 6 deletions github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,31 @@ type AddProjectItemOptions struct {
ID int64 `json:"id,omitempty"`
}

// ProjectV2FieldUpdate represents a field update for a project item.
// It contains the field ID and the new value to set.
//
// GitHub API docs: https://docs.github.com/rest/projects/items#update-project-item-for-organization
type ProjectV2FieldUpdate struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not?

Suggested change
type ProjectV2FieldUpdate struct {
type UpdateProjectV2Field struct {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been fixed as part of 3bbb927

// 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)
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 []*ProjectV2FieldUpdate `json:"fields,omitempty"`
}

// ListOrganizationProjectItems lists items for an organization owned project.
Expand Down Expand Up @@ -387,7 +404,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
}
Expand Down Expand Up @@ -481,7 +502,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
}
Expand Down
156 changes: 156 additions & 0 deletions github/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,44 @@ 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"
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)
Expand Down Expand Up @@ -897,6 +935,46 @@ 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: []*ProjectV2FieldUpdate{
{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"
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)
Expand Down Expand Up @@ -1040,6 +1118,44 @@ 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"
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)
Expand Down Expand Up @@ -1082,6 +1198,46 @@ 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: []*ProjectV2FieldUpdate{
{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"
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)
Expand Down
Loading