diff --git a/github/github-accessors.go b/github/github-accessors.go index ec77299f2fe..63a5dba57e1 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -10478,6 +10478,30 @@ func (f *FieldValue) GetProjectNumber() int64 { return *f.ProjectNumber } +// GetCreatedAt returns the CreatedAt field if it's non-nil, zero value otherwise. +func (f *FineGrainedPersonalAccessTokenRequest) GetCreatedAt() Timestamp { + if f == nil || f.CreatedAt == nil { + return Timestamp{} + } + return *f.CreatedAt +} + +// GetTokenExpiresAt returns the TokenExpiresAt field if it's non-nil, zero value otherwise. +func (f *FineGrainedPersonalAccessTokenRequest) GetTokenExpiresAt() Timestamp { + if f == nil || f.TokenExpiresAt == nil { + return Timestamp{} + } + return *f.TokenExpiresAt +} + +// GetTokenLastUsedAt returns the TokenLastUsedAt field if it's non-nil, zero value otherwise. +func (f *FineGrainedPersonalAccessTokenRequest) GetTokenLastUsedAt() Timestamp { + if f == nil || f.TokenLastUsedAt == nil { + return Timestamp{} + } + return *f.TokenLastUsedAt +} + // GetIdentifier returns the Identifier field if it's non-nil, zero value otherwise. func (f *FirstPatchedVersion) GetIdentifier() string { if f == nil || f.Identifier == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index 08332e3776d..b6909553416 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -13614,6 +13614,39 @@ func TestFieldValue_GetProjectNumber(tt *testing.T) { f.GetProjectNumber() } +func TestFineGrainedPersonalAccessTokenRequest_GetCreatedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + f := &FineGrainedPersonalAccessTokenRequest{CreatedAt: &zeroValue} + f.GetCreatedAt() + f = &FineGrainedPersonalAccessTokenRequest{} + f.GetCreatedAt() + f = nil + f.GetCreatedAt() +} + +func TestFineGrainedPersonalAccessTokenRequest_GetTokenExpiresAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + f := &FineGrainedPersonalAccessTokenRequest{TokenExpiresAt: &zeroValue} + f.GetTokenExpiresAt() + f = &FineGrainedPersonalAccessTokenRequest{} + f.GetTokenExpiresAt() + f = nil + f.GetTokenExpiresAt() +} + +func TestFineGrainedPersonalAccessTokenRequest_GetTokenLastUsedAt(tt *testing.T) { + tt.Parallel() + var zeroValue Timestamp + f := &FineGrainedPersonalAccessTokenRequest{TokenLastUsedAt: &zeroValue} + f.GetTokenLastUsedAt() + f = &FineGrainedPersonalAccessTokenRequest{} + f.GetTokenLastUsedAt() + f = nil + f.GetTokenLastUsedAt() +} + func TestFirstPatchedVersion_GetIdentifier(tt *testing.T) { tt.Parallel() var zeroValue string diff --git a/github/github-iterators.go b/github/github-iterators.go index cdff22217bb..96a0c7b37e1 100644 --- a/github/github-iterators.go +++ b/github/github-iterators.go @@ -4149,6 +4149,37 @@ func (s *OrganizationsService) ListFailedOrgInvitationsIter(ctx context.Context, } } +// ListFineGrainedPersonalAccessTokenRequestsIter returns an iterator that paginates through all results of ListFineGrainedPersonalAccessTokenRequests. +func (s *OrganizationsService) ListFineGrainedPersonalAccessTokenRequestsIter(ctx context.Context, org string, opts *ListFineGrainedPATOptions) iter.Seq2[*FineGrainedPersonalAccessTokenRequest, error] { + return func(yield func(*FineGrainedPersonalAccessTokenRequest, error) bool) { + // Create a copy of opts to avoid mutating the caller's struct + if opts == nil { + opts = &ListFineGrainedPATOptions{} + } else { + opts = Ptr(*opts) + } + + for { + results, resp, err := s.ListFineGrainedPersonalAccessTokenRequests(ctx, org, opts) + if err != nil { + yield(nil, err) + return + } + + for _, item := range results { + if !yield(item, nil) { + return + } + } + + if resp.NextPage == 0 { + break + } + opts.ListOptions.Page = resp.NextPage + } + } +} + // ListFineGrainedPersonalAccessTokensIter returns an iterator that paginates through all results of ListFineGrainedPersonalAccessTokens. func (s *OrganizationsService) ListFineGrainedPersonalAccessTokensIter(ctx context.Context, org string, opts *ListFineGrainedPATOptions) iter.Seq2[*PersonalAccessToken, error] { return func(yield func(*PersonalAccessToken, error) bool) { diff --git a/github/github-iterators_test.go b/github/github-iterators_test.go index fc299ce307a..c1d44bca2dc 100644 --- a/github/github-iterators_test.go +++ b/github/github-iterators_test.go @@ -9087,6 +9087,78 @@ func TestOrganizationsService_ListFailedOrgInvitationsIter(t *testing.T) { } } +func TestOrganizationsService_ListFineGrainedPersonalAccessTokenRequestsIter(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + var callNum int + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + callNum++ + switch callNum { + case 1: + w.Header().Set("Link", `; rel="next"`) + fmt.Fprint(w, `[{},{},{}]`) + case 2: + fmt.Fprint(w, `[{},{},{},{}]`) + case 3: + fmt.Fprint(w, `[{},{}]`) + case 4: + w.WriteHeader(http.StatusNotFound) + case 5: + fmt.Fprint(w, `[{},{}]`) + } + }) + + iter := client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter(t.Context(), "", nil) + var gotItems int + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 7; gotItems != want { + t.Errorf("client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter call 1 got %v items; want %v", gotItems, want) + } + + opts := &ListFineGrainedPATOptions{} + iter = client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter(t.Context(), "", opts) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + if want := 2; gotItems != want { + t.Errorf("client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter call 2 got %v items; want %v", gotItems, want) + } + + iter = client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter(t.Context(), "", nil) + gotItems = 0 + for _, err := range iter { + gotItems++ + if err == nil { + t.Error("expected error; got nil") + } + } + if gotItems != 1 { + t.Errorf("client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter call 3 got %v items; want 1 (an error)", gotItems) + } + + iter = client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter(t.Context(), "", nil) + gotItems = 0 + iter(func(item *FineGrainedPersonalAccessTokenRequest, err error) bool { + gotItems++ + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + return false + }) + if gotItems != 1 { + t.Errorf("client.Organizations.ListFineGrainedPersonalAccessTokenRequestsIter call 4 got %v items; want 1 (an error)", gotItems) + } +} + func TestOrganizationsService_ListFineGrainedPersonalAccessTokensIter(t *testing.T) { t.Parallel() client, mux, _ := setup(t) diff --git a/github/orgs_personal_access_tokens.go b/github/orgs_personal_access_tokens.go index 8155ad402bf..f09b436c12a 100644 --- a/github/orgs_personal_access_tokens.go +++ b/github/orgs_personal_access_tokens.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/url" + "strconv" "strings" ) @@ -83,6 +84,9 @@ type ListFineGrainedPATOptions struct { // This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ. LastUsedAfter string `url:"last_used_after,omitempty"` + // TokenID filters results by the given fine-grained personal access token IDs. + TokenID []int64 `url:"-"` + ListOptions } @@ -115,6 +119,75 @@ func (s *OrganizationsService) ListFineGrainedPersonalAccessTokens(ctx context.C return pats, resp, nil } +// FineGrainedPersonalAccessTokenRequest represents the details of a request to access organization resources via a fine-grained personal access token. +type FineGrainedPersonalAccessTokenRequest struct { + // Unique identifier of the request for access via fine-grained personal access token. + ID int64 `json:"id"` + + // Reason is the reason for the request. + Reason string `json:"reason"` + + // Owner is the GitHub user associated with the token. + Owner User `json:"owner"` + + // RepositorySelection is the type of repository selection requested. + // Possible values are: "none", "all", "subset". + RepositorySelection string `json:"repository_selection"` + + // URL to the list of repositories the fine-grained personal access token can access. + // Only follow when `repository_selection` is `subset`. + RepositoriesURL string `json:"repositories_url"` + + // Permissions are the permissions requested, categorized by type. + Permissions PersonalAccessTokenPermissions `json:"permissions"` + + // Date and time when the request was created. + CreatedAt *Timestamp `json:"created_at"` + + // Whether the associated fine-grained personal access token has expired. + TokenExpired bool `json:"token_expired"` + + // Date and time when the associated fine-grained personal access token expires. + TokenExpiresAt *Timestamp `json:"token_expires_at"` + + // TokenID + TokenID int64 `json:"token_id"` + + // TokenName + TokenName string `json:"token_name"` + + // Date and time when the associated fine-grained personal access token was last used for authentication. + TokenLastUsedAt *Timestamp `json:"token_last_used_at"` +} + +// ListFineGrainedPersonalAccessTokenRequests lists requests to access organization resources via fine-grained personal access tokens. +// Only GitHub Apps can call this API, using the `Personal access tokens` organization permissions (read). +// +// GitHub API docs: https://docs.github.com/rest/orgs/personal-access-tokens#list-requests-to-access-organization-resources-with-fine-grained-personal-access-tokens +// +//meta:operation GET /orgs/{org}/personal-access-token-requests +func (s *OrganizationsService) ListFineGrainedPersonalAccessTokenRequests(ctx context.Context, org string, opts *ListFineGrainedPATOptions) ([]*FineGrainedPersonalAccessTokenRequest, *Response, error) { + u := fmt.Sprintf("orgs/%v/personal-access-token-requests", org) + // The `owner` parameter is a special case that uses the `owner[]=...` format and needs a custom function to format it correctly. + u, err := addListFineGrainedPATOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, opts) + if err != nil { + return nil, nil, err + } + + var pats []*FineGrainedPersonalAccessTokenRequest + resp, err := s.client.Do(ctx, req, &pats) + if err != nil { + return nil, resp, err + } + + return pats, resp, nil +} + // ReviewPersonalAccessTokenRequestOptions specifies the parameters to the ReviewPersonalAccessTokenRequest method. type ReviewPersonalAccessTokenRequestOptions struct { Action string `json:"action"` @@ -139,16 +212,16 @@ func (s *OrganizationsService) ReviewPersonalAccessTokenRequest(ctx context.Cont return s.client.Do(ctx, req, nil) } -// addListFineGrainedPATOptions adds the owner parameter to the URL query string with the correct format if it is set. +// addListFineGrainedPATOptions adds the owner and token_id parameters to the URL query string with the correct format if they are set. // // GitHub API expects the owner parameter to be a list of strings in the `owner[]=...` format. -// For multiple owner values, the owner parameter is repeated in the query string. +// For multiple owner and token_id values, the owner and token_id parameters are repeated in the query string. // // Example: -// owner[]=user1&owner[]=user2 -// This will filter the results to only include fine-grained personal access tokens owned by `user1` and `user2`. +// owner[]=user1&owner[]=user2&token_id[]=123&token_id[]=456 +// This will filter the results to only include fine-grained personal access tokens owned by `user1` and `user2` and with token IDs `123` and `456`. // -// This function ensures the owner parameter is formatted correctly in the URL query string. +// This function ensures the owner and token_id parameters are formatted correctly in the URL query string. func addListFineGrainedPATOptions(s string, opts *ListFineGrainedPATOptions) (string, error) { u, err := addOptions(s, opts) if err != nil { @@ -172,6 +245,20 @@ func addListFineGrainedPATOptions(s string, opts *ListFineGrainedPATOptions) (st u += "?" + ownerQuery } } + if len(opts.TokenID) > 0 { + tokenIDVals := make([]string, len(opts.TokenID)) + for i, tokenID := range opts.TokenID { + tokenIDVals[i] = fmt.Sprintf("token_id[]=%v", url.QueryEscape(strconv.FormatInt(tokenID, 10))) + } + tokenIDQuery := strings.Join(tokenIDVals, "&") + + if strings.Contains(u, "?") { + u += "&" + tokenIDQuery + } else { + u += "?" + tokenIDQuery + } + return u, nil + } return u, nil } diff --git a/github/orgs_personal_access_tokens_test.go b/github/orgs_personal_access_tokens_test.go index 05cbeaaa0d6..760e83c2457 100644 --- a/github/orgs_personal_access_tokens_test.go +++ b/github/orgs_personal_access_tokens_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "time" @@ -158,6 +159,224 @@ func TestOrganizationsService_ListFineGrainedPersonalAccessTokens(t *testing.T) }) } +func TestOrganizationsService_ListFineGrainedPersonalAccessTokens_ownerOnly(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/personal-access-tokens", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + // When only Owner is set, addOptions adds no query params, so URL gets "?" + owner[]=... + if !strings.Contains(r.URL.RawQuery, "owner[]=") { + t.Errorf("Expected query to contain owner[]=, got %q", r.URL.RawQuery) + } + if strings.HasPrefix(r.URL.RawQuery, "&") { + t.Errorf("Expected query to start with ?, got %q", r.URL.RawQuery) + } + fmt.Fprint(w, "[]") + }) + + opts := &ListFineGrainedPATOptions{Owner: []string{"octocat"}} + ctx := t.Context() + _, _, err := client.Organizations.ListFineGrainedPersonalAccessTokens(ctx, "o", opts) + if err != nil { + t.Errorf("Organizations.ListFineGrainedPersonalAccessTokens returned error: %v", err) + } +} + +func TestOrganizationsService_ListFineGrainedPersonalAccessTokenRequests(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/personal-access-token-requests", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + expectedQuery := map[string][]string{ + "per_page": {"2"}, + "page": {"2"}, + "sort": {"created_at"}, + "direction": {"desc"}, + "owner[]": {"octocat", "octodog"}, + "token_id[]": {"11579703", "11579704"}, + } + + query := r.URL.Query() + for key, expectedValues := range expectedQuery { + actualValues := query[key] + if len(actualValues) != len(expectedValues) { + t.Errorf("Expected %v values for query param %v, got %v", len(expectedValues), key, len(actualValues)) + } + for i, expectedValue := range expectedValues { + if actualValues[i] != expectedValue { + t.Errorf("Expected query param %v to be %v, got %v", key, expectedValue, actualValues[i]) + } + } + } + + fmt.Fprint(w, `[ + { + "id": 1848980, + "reason": null, + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "repository_selection": "all", + "repositories_url": "https://api.github.com/organizations/135028681/personal-access-token-requests/1848980/repositories", + "permissions": { + "repository": { + "metadata": "read" + } + }, + "created_at": "2026-02-17T06:49:30Z", + "token_id": 11579703, + "token_name": "testFineGrained", + "token_expired": false, + "token_expires_at": "2026-04-18T06:49:30Z", + "token_last_used_at": null + } + ]`) + }) + + opts := &ListFineGrainedPATOptions{ + ListOptions: ListOptions{Page: 2, PerPage: 2}, + Sort: "created_at", + Direction: "desc", + Owner: []string{"octocat", "octodog"}, + TokenID: []int64{11579703, 11579704}, + } + ctx := t.Context() + requests, resp, err := client.Organizations.ListFineGrainedPersonalAccessTokenRequests(ctx, "o", opts) + if err != nil { + t.Errorf("Organizations.ListFineGrainedPersonalAccessTokenRequests returned error: %v", err) + } + + want := []*FineGrainedPersonalAccessTokenRequest{ + { + ID: 1848980, + Reason: "", + Owner: User{ + Login: Ptr("octocat"), + ID: Ptr(int64(1)), + NodeID: Ptr("MDQ6VXNlcjE="), + AvatarURL: Ptr("https://github.com/images/error/octocat_happy.gif"), + GravatarID: Ptr(""), + URL: Ptr("https://api.github.com/users/octocat"), + HTMLURL: Ptr("https://github.com/octocat"), + FollowersURL: Ptr("https://api.github.com/users/octocat/followers"), + FollowingURL: Ptr("https://api.github.com/users/octocat/following{/other_user}"), + GistsURL: Ptr("https://api.github.com/users/octocat/gists{/gist_id}"), + StarredURL: Ptr("https://api.github.com/users/octocat/starred{/owner}{/repo}"), + SubscriptionsURL: Ptr("https://api.github.com/users/octocat/subscriptions"), + OrganizationsURL: Ptr("https://api.github.com/users/octocat/orgs"), + ReposURL: Ptr("https://api.github.com/users/octocat/repos"), + EventsURL: Ptr("https://api.github.com/users/octocat/events{/privacy}"), + ReceivedEventsURL: Ptr("https://api.github.com/users/octocat/received_events"), + Type: Ptr("User"), + SiteAdmin: Ptr(false), + }, + RepositorySelection: "all", + RepositoriesURL: "https://api.github.com/organizations/135028681/personal-access-token-requests/1848980/repositories", + Permissions: PersonalAccessTokenPermissions{ + Repo: map[string]string{"metadata": "read"}, + }, + CreatedAt: &Timestamp{time.Date(2026, time.February, 17, 6, 49, 30, 0, time.UTC)}, + TokenID: 11579703, + TokenName: "testFineGrained", + TokenExpired: false, + TokenExpiresAt: &Timestamp{time.Date(2026, time.April, 18, 6, 49, 30, 0, time.UTC)}, + TokenLastUsedAt: nil, + }, + } + if !cmp.Equal(requests, want) { + t.Errorf("Organizations.ListFineGrainedPersonalAccessTokenRequests returned %+v, want %+v", requests, want) + } + + if resp == nil { + t.Error("Organizations.ListFineGrainedPersonalAccessTokenRequests returned nil response") + } + + const methodName = "ListFineGrainedPersonalAccessTokenRequests" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Organizations.ListFineGrainedPersonalAccessTokenRequests(ctx, "\n", opts) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Organizations.ListFineGrainedPersonalAccessTokenRequests(ctx, "o", opts) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestOrganizationsService_ListFineGrainedPersonalAccessTokenRequests_ownerOnly(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/personal-access-token-requests", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + // When only Owner is set (no ListOptions, TokenID, etc.), addOptions adds no query params, so URL gets "?" + owner[]=... + if !strings.Contains(r.URL.RawQuery, "owner[]=") { + t.Errorf("Expected query to contain owner[]=, got %q", r.URL.RawQuery) + } + if strings.HasPrefix(r.URL.RawQuery, "&") { + t.Errorf("Expected query to start with ?, got %q", r.URL.RawQuery) + } + fmt.Fprint(w, "[]") + }) + + opts := &ListFineGrainedPATOptions{ + Owner: []string{"octocat"}, + } + ctx := t.Context() + _, _, err := client.Organizations.ListFineGrainedPersonalAccessTokenRequests(ctx, "o", opts) + if err != nil { + t.Errorf("Organizations.ListFineGrainedPersonalAccessTokenRequests returned error: %v", err) + } +} + +func TestOrganizationsService_ListFineGrainedPersonalAccessTokenRequests_tokenIDOnly(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/orgs/o/personal-access-token-requests", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + // When only TokenID is set (no Owner, ListOptions, etc.), addOptions adds no query params, so URL gets "?" + token_id[]=... + if !strings.Contains(r.URL.RawQuery, "token_id[]=") { + t.Errorf("Expected query to contain token_id[]=, got %q", r.URL.RawQuery) + } + if strings.HasPrefix(r.URL.RawQuery, "&") { + t.Errorf("Expected query not to start with & (token_id should be first param with ?), got %q", r.URL.RawQuery) + } + fmt.Fprint(w, "[]") + }) + + opts := &ListFineGrainedPATOptions{ + TokenID: []int64{11579703}, + } + ctx := t.Context() + _, _, err := client.Organizations.ListFineGrainedPersonalAccessTokenRequests(ctx, "o", opts) + if err != nil { + t.Errorf("Organizations.ListFineGrainedPersonalAccessTokenRequests returned error: %v", err) + } +} + func TestOrganizationsService_ReviewPersonalAccessTokenRequest(t *testing.T) { t.Parallel() client, mux, _ := setup(t)