diff --git a/github/event_types.go b/github/event_types.go index 80b0cf0485b..f459c95839e 100644 --- a/github/event_types.go +++ b/github/event_types.go @@ -1111,35 +1111,6 @@ type ProjectV2Event struct { Sender *User `json:"sender,omitempty"` } -// ProjectV2 represents a v2 project. -type ProjectV2 struct { - ID *int64 `json:"id,omitempty"` - NodeID *string `json:"node_id,omitempty"` - Owner *User `json:"owner,omitempty"` - Creator *User `json:"creator,omitempty"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Public *bool `json:"public,omitempty"` - ClosedAt *Timestamp `json:"closed_at,omitempty"` - CreatedAt *Timestamp `json:"created_at,omitempty"` - UpdatedAt *Timestamp `json:"updated_at,omitempty"` - DeletedAt *Timestamp `json:"deleted_at,omitempty"` - Number *int `json:"number,omitempty"` - ShortDescription *string `json:"short_description,omitempty"` - DeletedBy *User `json:"deleted_by,omitempty"` - - // Fields migrated from the Project (classic) struct: - URL *string `json:"url,omitempty"` - HTMLURL *string `json:"html_url,omitempty"` - ColumnsURL *string `json:"columns_url,omitempty"` - OwnerURL *string `json:"owner_url,omitempty"` - Name *string `json:"name,omitempty"` - Body *string `json:"body,omitempty"` - State *string `json:"state,omitempty"` - OrganizationPermission *string `json:"organization_permission,omitempty"` - Private *bool `json:"private,omitempty"` -} - // ProjectV2ItemEvent is triggered when there is activity relating to an item on an organization-level project. // The Webhook event name is "projects_v2_item". // diff --git a/github/github-accessors.go b/github/github-accessors.go index 0a8f83ee2e1..55b3d1b3d9c 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -18814,6 +18814,30 @@ func (p *ProjectV2Event) GetSender() *User { return p.Sender } +// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise. +func (p *ProjectV2Field) GetCreatedAt() Timestamp { + if p == nil || p.CreatedAt == nil { + return Timestamp{} + } + return *p.CreatedAt +} + +// GetID returns the ID field if it's non-nil, zero value otherwise. +func (p *ProjectV2Field) GetID() int64 { + if p == nil || p.ID == nil { + return 0 + } + return *p.ID +} + +// GetUpdatedAt returns the UpdatedAt field if it's non-nil, zero value otherwise. +func (p *ProjectV2Field) GetUpdatedAt() Timestamp { + if p == nil || p.UpdatedAt == nil { + return Timestamp{} + } + return *p.UpdatedAt +} + // GetArchivedAt returns the ArchivedAt field if it's non-nil, zero value otherwise. func (p *ProjectV2Item) GetArchivedAt() Timestamp { if p == nil || p.ArchivedAt == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index 3c78a1436b8..d9199690e63 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -24416,6 +24416,39 @@ func TestProjectV2Event_GetSender(tt *testing.T) { p.GetSender() } +func TestProjectV2Field_GetCreatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + p := &ProjectV2Field{CreatedAt: &zeroValue} + p.GetCreatedAt() + p = &ProjectV2Field{} + p.GetCreatedAt() + p = nil + p.GetCreatedAt() +} + +func TestProjectV2Field_GetID(tt *testing.T) { + tt.Parallel() + var zeroValue int64 + p := &ProjectV2Field{ID: &zeroValue} + p.GetID() + p = &ProjectV2Field{} + p.GetID() + p = nil + p.GetID() +} + +func TestProjectV2Field_GetUpdatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + p := &ProjectV2Field{UpdatedAt: &zeroValue} + p.GetUpdatedAt() + p = &ProjectV2Field{} + p.GetUpdatedAt() + p = nil + p.GetUpdatedAt() +} + func TestProjectV2Item_GetArchivedAt(tt *testing.T) { tt.Parallel() var zeroValue Timestamp diff --git a/github/github-stringify_test.go b/github/github-stringify_test.go index 87f04155cb9..f4b73e12a9d 100644 --- a/github/github-stringify_test.go +++ b/github/github-stringify_test.go @@ -1529,6 +1529,39 @@ func TestPreReceiveHook_String(t *testing.T) { } } +func TestProjectV2_String(t *testing.T) { + t.Parallel() + v := ProjectV2{ + ID: Ptr(int64(0)), + NodeID: Ptr(""), + Owner: &User{}, + Creator: &User{}, + Title: Ptr(""), + Description: Ptr(""), + Public: Ptr(false), + ClosedAt: &Timestamp{}, + CreatedAt: &Timestamp{}, + UpdatedAt: &Timestamp{}, + DeletedAt: &Timestamp{}, + Number: Ptr(0), + ShortDescription: Ptr(""), + DeletedBy: &User{}, + URL: Ptr(""), + HTMLURL: Ptr(""), + ColumnsURL: Ptr(""), + OwnerURL: Ptr(""), + Name: Ptr(""), + Body: Ptr(""), + State: Ptr(""), + OrganizationPermission: Ptr(""), + Private: Ptr(false), + } + want := `github.ProjectV2{ID:0, NodeID:"", Owner:github.User{}, Creator:github.User{}, Title:"", Description:"", Public:false, ClosedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, CreatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, UpdatedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, DeletedAt:github.Timestamp{0001-01-01 00:00:00 +0000 UTC}, Number:0, ShortDescription:"", DeletedBy:github.User{}, URL:"", HTMLURL:"", ColumnsURL:"", OwnerURL:"", Name:"", Body:"", State:"", OrganizationPermission:"", Private:false}` + if got := v.String(); got != want { + t.Errorf("ProjectV2.String = %v, want %v", got, want) + } +} + func TestPullRequest_String(t *testing.T) { t.Parallel() v := PullRequest{ diff --git a/github/github.go b/github/github.go index 02602142887..188bd3897c4 100644 --- a/github/github.go +++ b/github/github.go @@ -218,6 +218,7 @@ type Client struct { Meta *MetaService Migrations *MigrationService Organizations *OrganizationsService + Projects *ProjectsService PullRequests *PullRequestsService RateLimit *RateLimitService Reactions *ReactionsService @@ -456,6 +457,7 @@ func (c *Client) initialize() { c.Meta = (*MetaService)(&c.common) c.Migrations = (*MigrationService)(&c.common) c.Organizations = (*OrganizationsService)(&c.common) + c.Projects = (*ProjectsService)(&c.common) c.PullRequests = (*PullRequestsService)(&c.common) c.RateLimit = (*RateLimitService)(&c.common) c.Reactions = (*ReactionsService)(&c.common) diff --git a/github/projects.go b/github/projects.go new file mode 100644 index 00000000000..9f562fee865 --- /dev/null +++ b/github/projects.go @@ -0,0 +1,218 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "context" + "fmt" +) + +// ProjectsService handles communication with the project V2 +// methods of the GitHub API. +// +// GitHub API docs: https://docs.github.com/rest/projects/projects +type ProjectsService service + +// ProjectV2 represents a v2 project. +type ProjectV2 struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Owner *User `json:"owner,omitempty"` + Creator *User `json:"creator,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Public *bool `json:"public,omitempty"` + ClosedAt *Timestamp `json:"closed_at,omitempty"` + CreatedAt *Timestamp `json:"created_at,omitempty"` + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + DeletedAt *Timestamp `json:"deleted_at,omitempty"` + Number *int `json:"number,omitempty"` + ShortDescription *string `json:"short_description,omitempty"` + DeletedBy *User `json:"deleted_by,omitempty"` + + // Fields migrated from the Project (classic) struct: + URL *string `json:"url,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + ColumnsURL *string `json:"columns_url,omitempty"` + OwnerURL *string `json:"owner_url,omitempty"` + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` + OrganizationPermission *string `json:"organization_permission,omitempty"` + Private *bool `json:"private,omitempty"` +} + +func (p ProjectV2) String() string { return Stringify(p) } + +// ListProjectsPaginationOptions specifies optional parameters to list projects for user / organization. +// +// Note: Pagination is powered by before/after cursor-style pagination. After the initial call, +// inspect the returned *Response. Use resp.After as the opts.After value to request +// the next page, and resp.Before as the opts.Before value to request the previous +// page. Set either Before or After for a request; if both are +// supplied GitHub API will return an error. PerPage controls the number of items +// per page (max 100 per GitHub API docs). +type ListProjectsPaginationOptions struct { + // A cursor, as given in the Link header. If specified, the query only searches for events before this cursor. + Before string `url:"before,omitempty"` + + // A cursor, as given in the Link header. If specified, the query only searches for events after this cursor. + After string `url:"after,omitempty"` + + // For paginated result sets, the number of results to include per page. + PerPage int `url:"per_page,omitempty"` +} + +// ListProjectsOptions specifies optional parameters to list projects for user / organization. +type ListProjectsOptions struct { + ListProjectsPaginationOptions + + // Q is an optional query string to limit results to projects of the specified type. + Query string `url:"q,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 display name of the option. + Name string `json:"name,omitempty"` + // The color associated with this option (e.g., "blue", "red"). + Color string `json:"color,omitempty"` + // An optional description for this option. + Description string `json:"description,omitempty"` +} + +// ProjectV2Field represents a field in a GitHub Projects V2 project. +// Fields define the structure and data types for project items. +// +// GitHub API docs: https://docs.github.com/rest/projects/fields +type ProjectV2Field struct { + ID *int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"dataType,omitempty"` + URL string `json:"url,omitempty"` + Options []*any `json:"options,omitempty"` + CreatedAt *Timestamp `json:"created_at,omitempty"` + UpdatedAt *Timestamp `json:"updated_at,omitempty"` +} + +// ListProjectsForOrg lists Projects V2 for an organization. +// +// GitHub API docs: https://docs.github.com/rest/projects/projects#list-projects-for-organization +// +//meta:operation GET /orgs/{org}/projectsV2 +func (s *ProjectsService) ListProjectsForOrg(ctx context.Context, org string, opts *ListProjectsOptions) ([]*ProjectV2, *Response, error) { + u := fmt.Sprintf("orgs/%v/projectsV2", org) + 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 + } + + var projects []*ProjectV2 + resp, err := s.client.Do(ctx, req, &projects) + if err != nil { + return nil, resp, err + } + return projects, resp, nil +} + +// GetProjectForOrg gets a Projects V2 project for an organization by ID. +// +// GitHub API docs: https://docs.github.com/rest/projects/projects#get-project-for-organization +// +//meta:operation GET /orgs/{org}/projectsV2/{project_number} +func (s *ProjectsService) GetProjectForOrg(ctx context.Context, org string, projectNumber int) (*ProjectV2, *Response, error) { + u := fmt.Sprintf("orgs/%v/projectsV2/%v", org, projectNumber) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + project := new(ProjectV2) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil +} + +// ListProjectsForUser lists Projects V2 for a user. +// +// GitHub API docs: https://docs.github.com/rest/projects/projects#list-projects-for-user +// +//meta:operation GET /users/{username}/projectsV2 +func (s *ProjectsService) ListProjectsForUser(ctx context.Context, username string, opts *ListProjectsOptions) ([]*ProjectV2, *Response, error) { + u := fmt.Sprintf("users/%v/projectsV2", username) + 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 + } + + var projects []*ProjectV2 + resp, err := s.client.Do(ctx, req, &projects) + if err != nil { + return nil, resp, err + } + return projects, resp, nil +} + +// GetProjectForUser gets a Projects V2 project for a user by ID. +// +// GitHub API docs: https://docs.github.com/rest/projects/projects#get-project-for-user +// +//meta:operation GET /users/{username}/projectsV2/{project_number} +func (s *ProjectsService) GetProjectForUser(ctx context.Context, username string, projectNumber int) (*ProjectV2, *Response, error) { + u := fmt.Sprintf("users/%v/projectsV2/%v", username, projectNumber) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + project := new(ProjectV2) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil +} + +// ListProjectFieldsForOrg lists Projects V2 for an organization. +// +// GitHub API docs: https://docs.github.com/rest/projects/fields#list-project-fields-for-organization +// +//meta:operation GET /orgs/{org}/projectsV2/{project_number}/fields +func (s *ProjectsService) ListProjectFieldsForOrg(ctx context.Context, org string, projectNumber int, opts *ListProjectsOptions) ([]*ProjectV2Field, *Response, error) { + u := fmt.Sprintf("orgs/%v/projectsV2/%v/fields", org, projectNumber) + 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 + } + + var fields []*ProjectV2Field + resp, err := s.client.Do(ctx, req, &fields) + if err != nil { + return nil, resp, err + } + return fields, resp, nil +} diff --git a/github/projects_test.go b/github/projects_test.go new file mode 100644 index 00000000000..0fa2d333d5c --- /dev/null +++ b/github/projects_test.go @@ -0,0 +1,498 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "context" + "fmt" + "net/http" + "testing" +) + +func TestProjectsService_ListProjectsForOrg(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + // Combined handler: supports initial test case and dual before/after validation scenario. + mux.HandleFunc("/orgs/o/projectsV2", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + q := r.URL.Query() + if q.Get("before") == "b" && q.Get("after") == "a" { + fmt.Fprint(w, `[]`) + return + } + // default expectation for main part of test + testFormValues(t, r, values{"q": "alpha", "after": "2", "before": "1"}) + fmt.Fprint(w, `[{"id":1,"title":"T1","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + }) + + opts := &ListProjectsOptions{Query: "alpha", ListProjectsPaginationOptions: ListProjectsPaginationOptions{After: "2", Before: "1"}} + ctx := context.Background() + projects, _, err := client.Projects.ListProjectsForOrg(ctx, "o", opts) + if err != nil { + t.Fatalf("Projects.ListProjectsForOrg returned error: %v", err) + } + if len(projects) != 1 || projects[0].GetID() != 1 || projects[0].GetTitle() != "T1" { + t.Fatalf("Projects.ListProjectsForOrg returned %+v", projects) + } + + const methodName = "ListProjectsForOrg" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.ListProjectsForOrg(ctx, "\n", opts) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.ListProjectsForOrg(ctx, "o", opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) + + // still allow both set (no validation enforced) – ensure it does not error + ctxBypass := context.WithValue(context.Background(), BypassRateLimitCheck, true) + if _, _, err = client.Projects.ListProjectsForOrg(ctxBypass, "o", &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{Before: "b", After: "a"}}); err != nil { + t.Fatalf("unexpected error when both before/after set: %v", err) + } +} + +func TestProjectsService_GetProjectForOrg(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/projectsV2/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"id":1,"title":"OrgProj","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}`) + }) + + ctx := context.Background() + project, _, err := client.Projects.GetProjectForOrg(ctx, "o", 1) + if err != nil { + t.Fatalf("Projects.GetProjectForOrg returned error: %v", err) + } + if project.GetID() != 1 || project.GetTitle() != "OrgProj" { + t.Fatalf("Projects.GetProjectForOrg returned %+v", project) + } + + const methodName = "GetProjectForOrg" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.GetProjectForOrg(ctx, "o", 1) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestProjectsService_ListUserProjects(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + // Combined handler: supports initial test case and dual before/after scenario. + mux.HandleFunc("/users/u/projectsV2", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + q := r.URL.Query() + if q.Get("before") == "b" && q.Get("after") == "a" { + fmt.Fprint(w, `[]`) + return + } + testFormValues(t, r, values{"q": "beta", "before": "1", "after": "2", "per_page": "2"}) + fmt.Fprint(w, `[{"id":2,"title":"UProj","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + }) + + opts := &ListProjectsOptions{Query: "beta", ListProjectsPaginationOptions: ListProjectsPaginationOptions{Before: "1", After: "2", PerPage: 2}} + ctx := context.Background() + var ctxBypass context.Context + projects, _, err := client.Projects.ListProjectsForUser(ctx, "u", opts) + if err != nil { + t.Fatalf("Projects.ListProjectsForUser returned error: %v", err) + } + if len(projects) != 1 || projects[0].GetID() != 2 || projects[0].GetTitle() != "UProj" { + t.Fatalf("Projects.ListProjectsForUser returned %+v", projects) + } + + const methodName = "ListProjectsForUser" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.ListProjectsForUser(ctx, "\n", opts) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.ListProjectsForUser(ctx, "u", opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) + + // still allow both set (no validation enforced) – ensure it does not error + ctxBypass = context.WithValue(context.Background(), BypassRateLimitCheck, true) + if _, _, err = client.Projects.ListProjectsForUser(ctxBypass, "u", &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{Before: "b", After: "a"}}); err != nil { + t.Fatalf("unexpected error when both before/after set: %v", err) + } +} + +func TestProjectsService_GetProjectForUser(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/users/u/projectsV2/2", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `{"id":2,"title":"UProj","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}`) + }) + + ctx := context.Background() + project, _, err := client.Projects.GetProjectForUser(ctx, "u", 2) + if err != nil { + t.Fatalf("Projects.GetProjectForUser returned error: %v", err) + } + if project.GetID() != 2 || project.GetTitle() != "UProj" { + t.Fatalf("Projects.GetProjectForUser returned %+v", project) + } + + const methodName = "GetProjectForUser" + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.GetProjectForUser(ctx, "u", 2) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestProjectsService_ListProjectsForOrg_pagination(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + // First page returns a Link header with rel="next" containing an after cursor (after=cursor2) + mux.HandleFunc("/orgs/o/projectsV2", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + after := q.Get("after") + before := q.Get("before") + if after == "" && before == "" { + // first request + w.Header().Set("Link", "; rel=\"next\"") + fmt.Fprint(w, `[{"id":1,"title":"P1","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + if after == "cursor2" { + // second request simulates a previous link + w.Header().Set("Link", "; rel=\"prev\"") + fmt.Fprint(w, `[{"id":2,"title":"P2","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + // unexpected state + http.Error(w, "unexpected query", http.StatusBadRequest) + }) + + ctx := context.Background() + first, resp, err := client.Projects.ListProjectsForOrg(ctx, "o", nil) + if err != nil { + t.Fatalf("first page error: %v", err) + } + if len(first) != 1 || first[0].GetID() != 1 { + t.Fatalf("unexpected first page %+v", first) + } + if resp.After != "cursor2" { + t.Fatalf("expected resp.After=cursor2 got %q", resp.After) + } + + // Use resp.After as opts.After for next page + opts := &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{After: resp.After}} + second, resp2, err := client.Projects.ListProjectsForOrg(ctx, "o", opts) + if err != nil { + t.Fatalf("second page error: %v", err) + } + if len(second) != 1 || second[0].GetID() != 2 { + t.Fatalf("unexpected second page %+v", second) + } + if resp2.Before != "cursor2" { + t.Fatalf("expected resp2.Before=cursor2 got %q", resp2.Before) + } +} + +func TestProjectsService_ListProjectsForUser_pagination(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/users/u/projectsV2", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + after := q.Get("after") + before := q.Get("before") + if after == "" && before == "" { // first page + w.Header().Set("Link", "; rel=\"next\"") + fmt.Fprint(w, `[{"id":10,"title":"UP1","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + if after == "ucursor2" { // second page provides prev + w.Header().Set("Link", "; rel=\"prev\"") + fmt.Fprint(w, `[{"id":11,"title":"UP2","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + http.Error(w, "unexpected query", http.StatusBadRequest) + }) + + ctx := context.Background() + first, resp, err := client.Projects.ListProjectsForUser(ctx, "u", nil) + if err != nil { + t.Fatalf("first page error: %v", err) + } + if len(first) != 1 || first[0].GetID() != 10 { + t.Fatalf("unexpected first page %+v", first) + } + if resp.After != "ucursor2" { + t.Fatalf("expected resp.After=ucursor2 got %q", resp.After) + } + + opts := &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{After: resp.After}} + second, resp2, err := client.Projects.ListProjectsForUser(ctx, "u", opts) + if err != nil { + t.Fatalf("second page error: %v", err) + } + if len(second) != 1 || second[0].GetID() != 11 { + t.Fatalf("unexpected second page %+v", second) + } + if resp2.Before != "ucursor2" { + t.Fatalf("expected resp2.Before=ucursor2 got %q", resp2.Before) + } +} + +func TestProjectsService_ListProjectFieldsForOrg(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/projectsV2/1/fields", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + q := r.URL.Query() + if q.Get("before") == "b" && q.Get("after") == "a" { + fmt.Fprint(w, `[]`) + return + } + testFormValues(t, r, values{"q": "text", "after": "2", "before": "1"}) + fmt.Fprint(w, `[ + { + "id": 1, + "node_id": "node_1", + "name": "Status", + "dataType": "single_select", + "url": "https://api.github.com/projects/1/fields/field1", + "options": [ + { + "id": "option1", + "name": "Todo", + "color": "blue", + "description": "Tasks to be done" + }, + { + "id": "option2", + "name": "In Progress", + "color": "yellow" + } + ], + "created_at": "2011-01-02T15:04:05Z", + "updated_at": "2012-01-02T15:04:05Z" + }, + { + "id": 2, + "node_id": "node_2", + "name": "Priority", + "dataType": "text", + "url": "https://api.github.com/projects/1/fields/field2", + "created_at": "2011-01-02T15:04:05Z", + "updated_at": "2012-01-02T15:04:05Z" + } + ]`) + }) + + opts := &ListProjectsOptions{Query: "text", ListProjectsPaginationOptions: ListProjectsPaginationOptions{After: "2", Before: "1"}} + ctx := context.Background() + fields, _, err := client.Projects.ListProjectFieldsForOrg(ctx, "o", 1, opts) + if err != nil { + t.Fatalf("Projects.ListProjectFieldsForOrg returned error: %v", err) + } + + if len(fields) != 2 { + t.Fatalf("Projects.ListProjectFieldsForOrg returned %d fields, want 2", len(fields)) + } + + field1 := fields[0] + if field1.ID == nil || *field1.ID != 1 || field1.Name != "Status" || field1.DataType != "single_select" { + t.Errorf("First field: got ID=%v, Name=%s, DataType=%s; want 1, Status, single_select", field1.ID, field1.Name, field1.DataType) + } + if len(field1.Options) != 2 { + t.Errorf("First field options: got %d, want 2", len(field1.Options)) + } else { + getName := func(o *any) string { + if o == nil || *o == nil { + return "" + } + switch v := (*o).(type) { + case map[string]any: + if n, ok := v["name"].(string); ok { + return n + } + default: + // fall back to fmt for debug; reflection can be added if needed. + } + return "" + } + name0, name1 := getName(field1.Options[0]), getName(field1.Options[1]) + if name0 != "Todo" || name1 != "In Progress" { + t.Errorf("First field option names: got %q, %q; want Todo, In Progress", name0, name1) + } + } + + // Validate second field (without options) + field2 := fields[1] + if field2.ID == nil || *field2.ID != 2 || field2.Name != "Priority" || field2.DataType != "text" { + t.Errorf("Second field: got ID=%v, Name=%s, DataType=%s; want 2, Priority, text", field2.ID, field2.Name, field2.DataType) + } + if len(field2.Options) != 0 { + t.Errorf("Second field options: got %d, want 0", len(field2.Options)) + } + + const methodName = "ListProjectFieldsForOrg" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Projects.ListProjectFieldsForOrg(ctx, "\n", 1, opts) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Projects.ListProjectFieldsForOrg(ctx, "o", 1, opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) + + // still allow both set (no validation enforced) – ensure it does not error + ctxBypass := context.WithValue(context.Background(), BypassRateLimitCheck, true) + if _, _, err = client.Projects.ListProjectFieldsForOrg(ctxBypass, "o", 1, &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{Before: "b", After: "a"}}); err != nil { + t.Fatalf("unexpected error when both before/after set: %v", err) + } +} + +func TestProjectsService_ListProjectFieldsForOrg_pagination(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + // First page returns a Link header with rel="next" containing an after cursor + mux.HandleFunc("/orgs/o/projectsV2/1/fields", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + after := q.Get("after") + before := q.Get("before") + if after == "" && before == "" { + // first request + w.Header().Set("Link", "; rel=\"next\"") + fmt.Fprint(w, `[{"id":1,"name":"Status","dataType":"single_select","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + if after == "cursor2" { + // second request simulates a previous link + w.Header().Set("Link", "; rel=\"prev\"") + fmt.Fprint(w, `[{"id":2,"name":"Priority","dataType":"text","created_at":"2011-01-02T15:04:05Z","updated_at":"2012-01-02T15:04:05Z"}]`) + return + } + // unexpected state + http.Error(w, "unexpected query", http.StatusBadRequest) + }) + + ctx := context.Background() + first, resp, err := client.Projects.ListProjectFieldsForOrg(ctx, "o", 1, nil) + if err != nil { + t.Fatalf("first page error: %v", err) + } + if len(first) != 1 || first[0].ID == nil || *first[0].ID != 1 { + t.Fatalf("unexpected first page %+v", first) + } + if resp.After != "cursor2" { + t.Fatalf("expected resp.After=cursor2 got %q", resp.After) + } + + opts := &ListProjectsOptions{ListProjectsPaginationOptions: ListProjectsPaginationOptions{After: resp.After}} + second, resp2, err := client.Projects.ListProjectFieldsForOrg(ctx, "o", 1, opts) + if err != nil { + t.Fatalf("second page error: %v", err) + } + if len(second) != 1 || second[0].ID == nil || *second[0].ID != 2 { + t.Fatalf("unexpected second page %+v", second) + } + if resp2.Before != "cursor2" { + t.Fatalf("expected resp2.Before=cursor2 got %q", resp2.Before) + } +} + +func TestProjectV2_Marshal(t *testing.T) { + t.Parallel() + testJSONMarshal(t, &ProjectV2{}, "{}") + + p := &ProjectV2{ + ID: Ptr(int64(10)), + Title: Ptr("Title"), + Description: Ptr("Desc"), + Public: Ptr(true), + CreatedAt: &Timestamp{referenceTime}, + UpdatedAt: &Timestamp{referenceTime}, + } + + want := `{ + "id": 10, + "title": "Title", + "description": "Desc", + "public": true, + "created_at": ` + referenceTimeStr + `, + "updated_at": ` + referenceTimeStr + ` + }` + + testJSONMarshal(t, p, want) +} + +func TestProjectV2Field_Marshal(t *testing.T) { + t.Parallel() + testJSONMarshal(t, &ProjectV2Field{}, "{}") // empty struct + testJSONMarshal(t, &ProjectV2FieldOption{}, "{}") // option struct still individually testable + + type optStruct struct { + Color string `json:"color,omitempty"` + Description string `json:"description,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + } + optVal := &optStruct{Color: "blue", Description: "Tasks to be done", ID: "option1", Name: "Todo"} + var optAny any = optVal + + field := &ProjectV2Field{ + ID: Ptr(int64(1)), + NodeID: "node_1", + Name: "Status", + DataType: "single_select", + URL: "https://api.github.com/projects/1/fields/field1", + Options: []*any{&optAny}, + CreatedAt: &Timestamp{referenceTime}, + UpdatedAt: &Timestamp{referenceTime}, + } + + want := `{ + "id": 1, + "node_id": "node_1", + "name": "Status", + "dataType": "single_select", + "url": "https://api.github.com/projects/1/fields/field1", + "options": [ + { + "id": "option1", + "name": "Todo", + "color": "blue", + "description": "Tasks to be done" + } + ], + "created_at": ` + referenceTimeStr + `, + "updated_at": ` + referenceTimeStr + ` + }` + + testJSONMarshal(t, field, want) +} diff --git a/test/integration/projects_test.go b/test/integration/projects_test.go new file mode 100644 index 00000000000..84939a47ce6 --- /dev/null +++ b/test/integration/projects_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build integration + +package integration + +import ( + "context" + "os" + "testing" + + "github.com/google/go-github/v75/github" +) + +// Integration tests for Projects V2 endpoints defined in github/projects.go. +// +// These tests are intentionally defensive. They only require minimal +// environment variables identifying a target org and user. Project numbers are +// discovered dynamically by first listing projects and selecting one. For item +// CRUD operations, the test creates a temporary repository & issue (where +// possible) and adds/removes that issue as a project item. If prerequisites +// (auth, env vars, permissions, presence of at least one project) are missing, +// the relevant sub-test is skipped so other integration tests can still run. +// +// Required / optional environment variables: +// GITHUB_AUTH_TOKEN (required for any of these tests to run) +// GITHUB_TEST_ORG (org login; required for org project tests) +// GITHUB_TEST_USER (user login; required for user project tests) +// GITHUB_TEST_REPO (repo name) + +func TestProjectsV2_Org(t *testing.T) { + if !checkAuth("TestProjectsV2_Org") { + return + } + org := os.Getenv("GITHUB_TEST_ORG") + if org == "" { + t.Skip("GITHUB_TEST_ORG not set") + } + + ctx := context.Background() + + opts := &github.ListProjectsOptions{} + // List projects for org; pick the first available project we can read. + projects, _, err := client.Projects.ListProjectsForOrg(ctx, org, opts) + if err != nil { + // If listing itself fails, abort this test. + t.Fatalf("Projects.ListProjectsForOrg returned error: %v", err) + } + if len(projects) == 0 { + t.Skipf("no Projects V2 found for org %s", org) + } + project := projects[0] + if project.Number == nil { + t.Skip("selected org project has nil Number field") + } + projectNumber := *project.Number + + // Re-fetch via Get to exercise endpoint explicitly. + proj, _, err := client.Projects.GetProjectForOrg(ctx, org, projectNumber) + if err != nil { + // Permission mismatch? Skip CRUD while still reporting failure would make the test fail; + // we want correctness so treat as fatal here. + t.Fatalf("Projects.GetProjectForOrg returned error: %v", err) + } + if proj.Number == nil || *proj.Number != projectNumber { + t.Fatalf("GetProjectForOrg returned unexpected project number: got %+v want %d", proj.Number, projectNumber) + } + + _, _, err = client.Projects.ListProjectFieldsForOrg(ctx, org, projectNumber, nil) + if err != nil { + t.Fatalf("Projects.ListProjectFieldsForOrg returned error: %v. Fields listing might require extra permissions", err) + } +} + +func TestProjectsV2_User(t *testing.T) { + if !checkAuth("TestProjectsV2_User") { + return + } + user := os.Getenv("GITHUB_TEST_USER") + if user == "" { + t.Skip("GITHUB_TEST_USER not set") + } + + ctx := context.Background() + opts := &github.ListProjectsOptions{} + projects, _, err := client.Projects.ListProjectsForUser(ctx, user, opts) + if err != nil { + t.Fatalf("Projects.ListProjectsForUser returned error: %v. This indicates API or permission issue", err) + } + if len(projects) == 0 { + t.Skipf("no Projects V2 found for user %s", user) + } + project := projects[0] + if project.Number == nil { + t.Skip("selected user project has nil Number field") + } + + proj, _, err := client.Projects.GetProjectForUser(ctx, user, *project.Number) + if err != nil { + // can't fetch specific project; treat as fatal + t.Fatalf("Projects.GetProjectForUser returned error: %v", err) + } + if proj.Number == nil || *proj.Number != *project.Number { + t.Fatalf("GetProjectForUser returned unexpected project number: got %+v want %d", proj.Number, *project.Number) + } +}