When a token (GitHub App, fine-grained PAT, or GITHUB_TOKEN) lacks the project permission, querying projectItems on a PR or issue fails with "Resource not accessible by integration" or "Resource not accessible by personal access token". ProjectsV2IgnorableError did not match these errors, causing commands like pr view, pr edit, and issue view to fail entirely instead of gracefully omitting project data. Add "Resource not accessible by" as an ignorable error prefix. This is safe because ProjectsV2IgnorableError is only called in project-specific code paths. Closes #13280 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
359 lines
11 KiB
Go
359 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"unicode"
|
|
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/pkg/httpmock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestUpdateProjectV2Items(t *testing.T) {
|
|
var tests = []struct {
|
|
name string
|
|
httpStubs func(*httpmock.Registry)
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "updates project items",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
|
httpmock.GraphQLQuery(`{"data":{"add_000":{"item":{"id":"1"}},"delete_001":{"item":{"id":"2"}}}}`,
|
|
func(mutations string, inputs map[string]interface{}) {
|
|
expectedMutations := `
|
|
mutation UpdateProjectV2Items(
|
|
$input_000: AddProjectV2ItemByIdInput!
|
|
$input_001: AddProjectV2ItemByIdInput!
|
|
$input_002: DeleteProjectV2ItemInput!
|
|
$input_003: DeleteProjectV2ItemInput!
|
|
) {
|
|
add_000: addProjectV2ItemById(input: $input_000) { item { id } }
|
|
add_001: addProjectV2ItemById(input: $input_001) { item { id } }
|
|
delete_002: deleteProjectV2Item(input: $input_002) { deletedItemId }
|
|
delete_003: deleteProjectV2Item(input: $input_003) { deletedItemId }
|
|
}`
|
|
assert.Equal(t, stripSpace(expectedMutations), stripSpace(mutations))
|
|
if len(inputs) != 4 {
|
|
t.Fatalf("expected 4 inputs, got %d", len(inputs))
|
|
}
|
|
i0 := inputs["input_000"].(map[string]interface{})
|
|
i1 := inputs["input_001"].(map[string]interface{})
|
|
i2 := inputs["input_002"].(map[string]interface{})
|
|
i3 := inputs["input_003"].(map[string]interface{})
|
|
adds := []string{
|
|
fmt.Sprintf("%v -> %v", i0["contentId"], i0["projectId"]),
|
|
fmt.Sprintf("%v -> %v", i1["contentId"], i1["projectId"]),
|
|
}
|
|
removes := []string{
|
|
fmt.Sprintf("%v x %v", i2["itemId"], i2["projectId"]),
|
|
fmt.Sprintf("%v x %v", i3["itemId"], i3["projectId"]),
|
|
}
|
|
sort.Strings(adds)
|
|
sort.Strings(removes)
|
|
assert.Equal(t, []string{"item1 -> project1", "item2 -> project2"}, adds)
|
|
assert.Equal(t, []string{"item3 x project3", "item4 x project4"}, removes)
|
|
}))
|
|
},
|
|
},
|
|
{
|
|
name: "fails to update project items",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
|
httpmock.GraphQLMutation(`{"data":{}, "errors": [{"message": "some gql error"}]}`, func(inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(reg)
|
|
}
|
|
client := newTestClient(reg)
|
|
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
|
addProjectItems := map[string]string{"project1": "item1", "project2": "item2"}
|
|
deleteProjectItems := map[string]string{"project3": "item3", "project4": "item4"}
|
|
err := UpdateProjectV2Items(client, repo, addProjectItems, deleteProjectItems)
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectsV2ItemsForIssue(t *testing.T) {
|
|
var tests = []struct {
|
|
name string
|
|
httpStubs func(*httpmock.Registry)
|
|
expectItems ProjectItems
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "retrieves project items for issue",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{"repository":{"issue":{"projectItems":{"nodes": [{"id":"projectItem1"},{"id":"projectItem2"}]}}}}}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectItems: ProjectItems{
|
|
Nodes: []*ProjectV2Item{
|
|
{ID: "projectItem1"},
|
|
{ID: "projectItem2"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "fails to retrieve project items for issue",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "skips null project items for issue",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query IssueProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{"repository":{"issue":{"projectItems":{"totalCount":1,"nodes":[null]}}}}}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectItems: ProjectItems{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(reg)
|
|
}
|
|
client := newTestClient(reg)
|
|
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
|
issue := &Issue{Number: 1}
|
|
err := ProjectsV2ItemsForIssue(client, repo, issue)
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
assert.Equal(t, tt.expectItems, issue.ProjectItems)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectsV2ItemsForPullRequest(t *testing.T) {
|
|
var tests = []struct {
|
|
name string
|
|
httpStubs func(*httpmock.Registry)
|
|
expectItems ProjectItems
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "retrieves project items for pull request",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{"repository":{"pullRequest":{"projectItems":{"nodes": [{"id":"projectItem3"},{"id":"projectItem4"}]}}}}}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectItems: ProjectItems{
|
|
Nodes: []*ProjectV2Item{
|
|
{ID: "projectItem3"},
|
|
{ID: "projectItem4"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "fails to retrieve project items for pull request",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "skips null project items for pull request",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{"data":{"repository":{"pullRequest":{"projectItems":{"totalCount":1,"nodes":[null]}}}}}`,
|
|
func(query string, inputs map[string]interface{}) {}),
|
|
)
|
|
},
|
|
expectItems: ProjectItems{},
|
|
},
|
|
{
|
|
name: "retrieves project items that have status columns",
|
|
httpStubs: func(reg *httpmock.Registry) {
|
|
reg.Register(
|
|
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
|
httpmock.GraphQLQuery(`{
|
|
"data": {
|
|
"repository": {
|
|
"pullRequest": {
|
|
"projectItems": {
|
|
"nodes": [
|
|
{
|
|
"id": "PVTI_lADOB-vozM4AVk16zgK6U50",
|
|
"project": {
|
|
"id": "PVT_kwDOB-vozM4AVk16",
|
|
"title": "Test Project"
|
|
},
|
|
"status": {
|
|
"optionId": "47fc9ee4",
|
|
"name": "In Progress"
|
|
}
|
|
}
|
|
],
|
|
"pageInfo": {
|
|
"hasNextPage": false,
|
|
"endCursor": "MQ"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
func(query string, inputs map[string]interface{}) {
|
|
require.Equal(t, float64(1), inputs["number"])
|
|
require.Equal(t, "OWNER", inputs["owner"])
|
|
require.Equal(t, "REPO", inputs["name"])
|
|
}),
|
|
)
|
|
},
|
|
expectItems: ProjectItems{
|
|
Nodes: []*ProjectV2Item{
|
|
{
|
|
ID: "PVTI_lADOB-vozM4AVk16zgK6U50",
|
|
Project: ProjectV2ItemProject{
|
|
ID: "PVT_kwDOB-vozM4AVk16",
|
|
Title: "Test Project",
|
|
},
|
|
Status: ProjectV2ItemStatus{
|
|
OptionID: "47fc9ee4",
|
|
Name: "In Progress",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
if tt.httpStubs != nil {
|
|
tt.httpStubs(reg)
|
|
}
|
|
client := newTestClient(reg)
|
|
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
|
pr := &PullRequest{Number: 1}
|
|
err := ProjectsV2ItemsForPullRequest(client, repo, pr)
|
|
if tt.expectError {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
require.Equal(t, tt.expectItems, pr.ProjectItems)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectsV2IgnorableError(t *testing.T) {
|
|
var tests = []struct {
|
|
name string
|
|
errMsg string
|
|
expectOut bool
|
|
}{
|
|
{
|
|
name: "read scope error",
|
|
errMsg: "field requires one of the following scopes: ['read:project']",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "repository projectsV2 field error",
|
|
errMsg: "Field 'projectsV2' doesn't exist on type 'Repository'",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "organization projectsV2 field error",
|
|
errMsg: "Field 'projectsV2' doesn't exist on type 'Organization'",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "issue projectItems field error",
|
|
errMsg: "Field 'projectItems' doesn't exist on type 'Issue'",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "pullRequest projectItems field error",
|
|
errMsg: "Field 'projectItems' doesn't exist on type 'PullRequest'",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "resource not accessible by integration",
|
|
errMsg: "Resource not accessible by integration",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "resource not accessible by personal access token",
|
|
errMsg: "Resource not accessible by personal access token",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "resource not accessible by integration with path context",
|
|
errMsg: "GraphQL: Resource not accessible by integration (repository.pullRequest.projectItems.nodes.0)",
|
|
expectOut: true,
|
|
},
|
|
{
|
|
name: "other error",
|
|
errMsg: "some other graphql error message",
|
|
expectOut: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := errors.New(tt.errMsg)
|
|
out := ProjectsV2IgnorableError(err)
|
|
assert.Equal(t, tt.expectOut, out)
|
|
})
|
|
}
|
|
}
|
|
|
|
func stripSpace(str string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(str))
|
|
for _, ch := range str {
|
|
if !unicode.IsSpace(ch) {
|
|
b.WriteRune(ch)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|