Files
Bryan Zwicker 2198e8599b Add visible fields to project views (#2988)
* Add visible fields to project views

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1421a5d5-fdce-4c0e-9528-56d555ec30d4

* Fail fast and surface orphaned views on project view writes

Reject roadmap layouts before enumerating project fields in both the
create and update paths, and verify view ownership before resolving
visible fields on update, so rejected requests no longer pay for a
paginated field listing.

Skip the follow-up filter mutation when the filter is explicitly null,
since a new view has no filter to clear, and include the created view ID
when cleanup after a failed filter mutation also fails so the caller can
recover the orphaned view.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1421a5d5-fdce-4c0e-9528-56d555ec30d4
2026-08-12 09:40:51 -04:00

1787 lines
58 KiB
Go

package github
import (
"context"
"encoding/json"
"maps"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_ProjectsWrite_CreateProject(t *testing.T) {
t.Parallel()
toolDef := ProjectsWrite(translations.NullTranslationHelper)
t.Run("success user project", func(t *testing.T) {
t.Parallel()
mockedClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
User struct {
ID string
} `graphql:"user(login: $login)"`
}{},
map[string]any{
"login": githubv4.String("octocat"),
},
githubv4mock.DataResponse(map[string]any{
"user": map[string]any{
"id": "U_octocat",
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
CreateProjectV2 struct {
ProjectV2 struct {
ID string
Number int
Title string
URL string
}
} `graphql:"createProjectV2(input: $input)"`
}{},
githubv4.CreateProjectV2Input{
OwnerID: githubv4.ID("U_octocat"),
Title: githubv4.String("New Project"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2": map[string]any{
"projectV2": map[string]any{
"id": "PVT_project123",
"number": 1,
"title": "New Project",
"url": "https://github.com/users/octocat/projects/1",
},
},
}),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(mockedClient),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project",
"owner": "octocat",
"owner_type": "user",
"title": "New Project",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var response map[string]any
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.Equal(t, "PVT_project123", response["id"])
assert.Equal(t, float64(1), response["number"])
assert.Equal(t, "New Project", response["title"])
assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"])
})
t.Run("missing owner_type returns error", func(t *testing.T) {
t.Parallel()
deps := BaseDeps{
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project",
"owner": "octocat",
"title": "New Project",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "owner_type is required")
})
t.Run("invalid owner_type returns error", func(t *testing.T) {
t.Parallel()
deps := BaseDeps{
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project",
"owner": "octocat",
"owner_type": "invalid",
"title": "New Project",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "invalid owner_type")
assert.Contains(t, textContent.Text, "must be")
})
}
// resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving
// an org project node ID via resolveProjectNodeID.
func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"id": nodeID,
},
},
}),
)
}
func resolveProjectNodeIDUserMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
User struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"user(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant
},
githubv4mock.DataResponse(map[string]any{
"user": map[string]any{
"projectV2": map[string]any{
"id": nodeID,
},
},
}),
)
}
func projectViewParentMatcher(viewID, projectID string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
projectViewParentQuery{},
map[string]any{"id": githubv4.ID(viewID)},
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": viewID,
"layout": "TABLE_LAYOUT",
"project": map[string]any{"id": projectID},
},
}),
)
}
func projectViewParentErrorMatcher(viewID, message string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
projectViewParentQuery{},
map[string]any{"id": githubv4.ID(viewID)},
githubv4mock.ErrorResponse(message),
)
}
// countingGraphQLClient wraps a mocked GraphQL client and reports how many requests it served.
func countingGraphQLClient(matchers ...githubv4mock.Matcher) (*http.Client, func() int) {
client := githubv4mock.NewMockedHTTPClient(matchers...)
counter := &countingRoundTripper{next: client.Transport}
client.Transport = counter
return client, func() int { return int(counter.count.Load()) }
}
type countingRoundTripper struct {
next http.RoundTripper
count atomic.Int64
}
func (c *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
c.count.Add(1)
return c.next.RoundTrip(req)
}
func projectFieldNamesMatcher(owner, ownerType string, projectNumber int, nodes []map[string]any) githubv4mock.Matcher {
var response map[string]any
if ownerType == "org" {
response = fieldsResponse(nodes)
return githubv4mock.NewQueryMatcher(
projectFieldsQueryOrg{},
fieldsQueryVars(owner, projectNumber),
githubv4mock.DataResponse(response),
)
}
response = map[string]any{
"user": map[string]any{
"projectV2": map[string]any{
"fields": map[string]any{
"nodes": nodes,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}
return githubv4mock.NewQueryMatcher(
projectFieldsQueryUser{},
fieldsQueryVars(owner, projectNumber),
githubv4mock.DataResponse(response),
)
}
func projectViewResponse(id string, number int, name, layout, filter string, visibleFieldIDs ...int) map[string]any {
nodes := make([]map[string]any, 0, len(visibleFieldIDs))
for _, fieldID := range visibleFieldIDs {
nodes = append(nodes, map[string]any{"databaseId": fieldID})
}
return map[string]any{
"id": id,
"number": number,
"name": name,
"layout": layout,
"filter": filter,
"configuration": map[string]any{
"visibleFields": map[string]any{"nodes": nodes},
},
}
}
func createFieldMatcher() githubv4mock.Matcher {
return githubv4mock.NewMutationMatcher(
struct {
CreateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"createProjectV2Field(input: $input)"`
}{},
githubv4.CreateProjectV2FieldInput{
ProjectID: githubv4.ID("PVT_project1"),
DataType: githubv4.ProjectV2CustomFieldType("ITERATION"),
Name: githubv4.String("Sprint"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2Field": map[string]any{
"projectV2Field": map[string]any{
"id": "PVTIF_field1",
"name": "Sprint",
},
},
}),
)
}
func updateFieldIterationResponse() githubv4mock.GQLResponse {
return githubv4mock.DataResponse(map[string]any{
"updateProjectV2Field": map[string]any{
"projectV2Field": map[string]any{
"id": "PVTIF_field1",
"name": "Sprint",
"configuration": map[string]any{
"iterations": []any{
map[string]any{
"id": "PVTI_iter1",
"title": "Sprint 1",
"startDate": "2025-01-20",
"duration": 7,
},
},
},
},
},
})
}
func Test_ProjectsWrite_CreateIterationField(t *testing.T) {
t.Parallel()
toolDef := ProjectsWrite(translations.NullTranslationHelper)
t.Run("success with iterations", func(t *testing.T) {
t.Parallel()
mockGQLClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
createFieldMatcher(),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
Configuration struct {
Iterations []struct {
ID string
Title string
StartDate string
Duration int
}
}
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"updateProjectV2Field(input: $input)"`
}{},
UpdateProjectV2FieldInput{
FieldID: githubv4.ID("PVTIF_field1"),
IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
Duration: githubv4.Int(7),
StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
Iterations: []ProjectV2IterationFieldIterationInput{
{
Title: githubv4.String("Sprint 1"),
StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
Duration: githubv4.Int(7),
},
},
},
},
nil,
updateFieldIterationResponse(),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(mockGQLClient),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_iteration_field",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"field_name": "Sprint",
"iteration_duration": float64(7),
"start_date": "2025-01-20",
"iterations": []any{
map[string]any{
"title": "Sprint 1",
"start_date": "2025-01-20",
"duration": float64(7),
},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var response map[string]any
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.Equal(t, "PVTIF_field1", response["id"])
})
t.Run("success without iterations", func(t *testing.T) {
t.Parallel()
mockGQLClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
createFieldMatcher(),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
Configuration struct {
Iterations []struct {
ID string
Title string
StartDate string
Duration int
}
}
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"updateProjectV2Field(input: $input)"`
}{},
UpdateProjectV2FieldInput{
FieldID: githubv4.ID("PVTIF_field1"),
IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
Duration: githubv4.Int(7),
StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
Iterations: []ProjectV2IterationFieldIterationInput{},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2Field": map[string]any{
"projectV2Field": map[string]any{
"id": "PVTIF_field1",
"name": "Sprint",
"configuration": map[string]any{
"iterations": []any{},
},
},
},
}),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(mockGQLClient),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_iteration_field",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"field_name": "Sprint",
"iteration_duration": float64(7),
"start_date": "2025-01-20",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var response map[string]any
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.Equal(t, "PVTIF_field1", response["id"])
})
t.Run("success with auto-detected owner_type", func(t *testing.T) {
t.Parallel()
// detectOwnerType uses REST to probe user first, then org
mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{
"id": 1,
"node_id": "PVT_project1",
"title": "Org Project",
}),
})
mockGQLClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"),
createFieldMatcher(),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
Configuration struct {
Iterations []struct {
ID string
Title string
StartDate string
Duration int
}
}
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"updateProjectV2Field(input: $input)"`
}{},
UpdateProjectV2FieldInput{
FieldID: githubv4.ID("PVTIF_field1"),
IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
Duration: githubv4.Int(14),
StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)},
Iterations: []ProjectV2IterationFieldIterationInput{},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2Field": map[string]any{
"projectV2Field": map[string]any{
"id": "PVTIF_field1",
"name": "Sprint",
"configuration": map[string]any{
"iterations": []any{},
},
},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, mockRESTClient),
GQLClient: githubv4.NewClient(mockGQLClient),
Obsv: stubExporters(),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_iteration_field",
"owner": "octo-org",
"project_number": float64(1),
"field_name": "Sprint",
"iteration_duration": float64(14),
"start_date": "2025-02-01",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
var response map[string]any
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
assert.Equal(t, "PVTIF_field1", response["id"])
})
}
func Test_ProjectsList_ListProjectViews(t *testing.T) {
toolDef := ProjectsList(translations.NullTranslationHelper)
t.Run("lists organization views with forward pagination and IFC", func(t *testing.T) {
first := githubv4.Int(2)
after := githubv4.String("after-cursor")
matcher := githubv4mock.NewQueryMatcher(
projectViewsOrgQuery{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(7),
"first": &first,
"after": &after,
"last": (*githubv4.Int)(nil),
"before": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"id": "PVT_project7",
"public": false,
"views": map[string]any{
"nodes": []map[string]any{
{
"id": "PVTV_view1",
"number": 1,
"name": "Ready work",
"layout": "TABLE_LAYOUT",
"filter": "status:Ready",
"configuration": map[string]any{
"visibleFields": map[string]any{
"nodes": []map[string]any{{"databaseId": 101}, {"databaseId": 202}},
},
},
},
},
"pageInfo": map[string]any{
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "start-cursor",
"endCursor": "end-cursor",
},
},
},
},
}),
)
matcher.Variables["first"] = first
matcher.Variables["after"] = after
gqlClient := githubv4mock.NewMockedHTTPClient(
matcher,
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_views",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"per_page": float64(2),
"after": "after-cursor",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response struct {
Views []MinimalProjectView `json:"views"`
PageInfo map[string]any `json:"pageInfo"`
}
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
require.Len(t, response.Views, 1)
assert.Equal(t, MinimalProjectView{
ID: "PVTV_view1",
Number: 1,
Name: "Ready work",
Layout: "table",
Filter: "status:Ready",
VisibleFields: []int64{101, 202},
}, response.Views[0])
assert.Equal(t, "end-cursor", response.PageInfo["nextCursor"])
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("lists user views with backward pagination", func(t *testing.T) {
last := githubv4.Int(3)
before := githubv4.String("before-cursor")
matcher := githubv4mock.NewQueryMatcher(
projectViewsUserQuery{},
map[string]any{
"owner": githubv4.String("octocat"),
"projectNumber": githubv4.Int(8),
"first": (*githubv4.Int)(nil),
"after": (*githubv4.String)(nil),
"last": &last,
"before": &before,
},
githubv4mock.DataResponse(map[string]any{
"user": map[string]any{
"projectV2": map[string]any{
"id": "PVT_project8",
"public": true,
"views": map[string]any{
"nodes": []map[string]any{},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": true,
"startCursor": "previous-cursor",
"endCursor": "",
},
},
},
},
}),
)
matcher.Variables["last"] = last
matcher.Variables["before"] = before
gqlClient := githubv4mock.NewMockedHTTPClient(
matcher,
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_views",
"owner": "octocat",
"owner_type": "user",
"project_number": float64(8),
"per_page": float64(3),
"before": "before-cursor",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
pageInfo := response["pageInfo"].(map[string]any)
assert.Equal(t, "previous-cursor", pageInfo["prevCursor"])
})
t.Run("rejects conflicting cursors", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_views",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"after": "a",
"before": "b",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "provide either 'after' or 'before'")
})
}
func Test_ProjectsGet_GetProjectView(t *testing.T) {
toolDef := ProjectsGet(translations.NullTranslationHelper)
t.Run("gets a private project view by node ID", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectViewNodeQuery{},
map[string]any{"id": githubv4.ID("PVTV_view1")},
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": "PVTV_view1",
"number": 1,
"name": "Ready work",
"layout": "BOARD_LAYOUT",
"filter": "status:Ready",
"project": map[string]any{"public": false},
"configuration": map[string]any{
"visibleFields": map[string]any{
"nodes": []map[string]any{{"databaseId": 101}, {"databaseId": 202}},
},
},
},
}),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(gqlClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project_view",
"view_id": "PVTV_view1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
var view MinimalProjectView
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &view))
assert.Equal(t, "PVTV_view1", view.ID)
assert.Equal(t, "board", view.Layout)
assert.Equal(t, []int64{101, 202}, view.VisibleFields)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("rejects a missing or wrong node type", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectViewNodeQuery{},
map[string]any{"id": githubv4.ID("I_issue1")},
githubv4mock.DataResponse(map[string]any{"node": map[string]any{}}),
),
)
deps := BaseDeps{GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project_view",
"view_id": "I_issue1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "node is not a ProjectV2View or was not found")
})
}
func Test_ProjectsWrite_CreateProjectView(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
emptyRESTClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
t.Run("creates an ordered view and preserves create filter support", func(t *testing.T) {
filter := githubv4.String("status:Ready")
gqlClient := githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octo-org", "org", 7, []map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", nil),
multiSelectFieldNode("PVTMSSF_teams", 202, "Teams"),
}),
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project7"),
Name: githubv4.String("Ready work"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
Configuration: &ProjectV2ViewConfigurationInput{
VisibleFieldIDs: []githubv4.ID{"PVTMSSF_teams", "PVTSSF_status"},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view1", 1, "Ready work", "TABLE_LAYOUT", "", 202, 101),
},
}),
),
githubv4mock.NewMutationMatcher(
updateProjectV2ViewMutation{},
UpdateProjectV2ViewInput{ViewID: githubv4.ID("PVTV_view1"), Filter: &filter},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view1", 1, "Ready work", "TABLE_LAYOUT", "status:Ready", 202, 101),
},
}),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Ready work",
"layout": "table",
"filter": "status:Ready",
"visible_field_names": []any{"Teams", "Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var view MinimalProjectView
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &view))
assert.Equal(t, []int64{202, 101}, view.VisibleFields)
assert.Equal(t, "status:Ready", view.Filter)
})
t.Run("keeps omitted configuration omitted", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDUserMatcher("octocat", 8, "PVT_project8"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project8"),
Name: githubv4.String("Board"),
Layout: githubv4.ProjectV2ViewLayoutBoardLayout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view2", 2, "Board", "BOARD_LAYOUT", ""),
},
}),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octocat",
"owner_type": "user",
"project_number": float64(8),
"name": "Board",
"layout": "board",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
assert.JSONEq(t, `{"id":"PVTV_view2","number":2,"name":"Board","layout":"board","filter":"","visible_fields":[]}`, getTextResult(t, result).Text)
})
t.Run("cleans up when applying a create filter fails", func(t *testing.T) {
filter := githubv4.String("status:Ready")
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project7"),
Name: githubv4.String("Filtered"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_cleanup", 4, "Filtered", "TABLE_LAYOUT", ""),
},
}),
),
githubv4mock.NewMutationMatcher(
updateProjectV2ViewMutation{},
UpdateProjectV2ViewInput{ViewID: githubv4.ID("PVTV_cleanup"), Filter: &filter},
nil,
githubv4mock.ErrorResponse("filter failed"),
),
githubv4mock.NewMutationMatcher(
struct {
DeleteProjectV2View struct {
ProjectV2View struct {
ID githubv4.ID
} `graphql:"projectV2View"`
} `graphql:"deleteProjectV2View(input: $input)"`
}{},
DeleteProjectV2ViewInput{ViewID: githubv4.ID("PVTV_cleanup")},
nil,
githubv4mock.DataResponse(map[string]any{
"deleteProjectV2View": map[string]any{
"projectV2View": map[string]any{"id": "PVTV_cleanup"},
},
}),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Filtered",
"layout": "table",
"filter": "status:Ready",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "filter failed")
assert.Contains(t, getTextResult(t, result).Text, "created view was cleaned up")
})
t.Run("returns the orphaned view ID when cleanup fails", func(t *testing.T) {
filter := githubv4.String("status:Ready")
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project7"),
Name: githubv4.String("Filtered"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_orphan", 4, "Filtered", "TABLE_LAYOUT", ""),
},
}),
),
githubv4mock.NewMutationMatcher(
updateProjectV2ViewMutation{},
UpdateProjectV2ViewInput{ViewID: githubv4.ID("PVTV_orphan"), Filter: &filter},
nil,
githubv4mock.ErrorResponse("filter failed"),
),
githubv4mock.NewMutationMatcher(
struct {
DeleteProjectV2View struct {
ProjectV2View struct {
ID githubv4.ID
} `graphql:"projectV2View"`
} `graphql:"deleteProjectV2View(input: $input)"`
}{},
DeleteProjectV2ViewInput{ViewID: githubv4.ID("PVTV_orphan")},
nil,
githubv4mock.ErrorResponse("cleanup failed"),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Filtered",
"layout": "table",
"filter": "status:Ready",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
text := getTextResult(t, result).Text
assert.Contains(t, text, "filter failed")
assert.Contains(t, text, "cleanup failed")
assert.Contains(t, text, "PVTV_orphan")
})
t.Run("skips the filter mutation when the filter is null", func(t *testing.T) {
// Only the create mutation is registered, so a follow-up filter mutation would 404.
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project7"),
Name: githubv4.String("Unfiltered"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_nullfilter", 5, "Unfiltered", "TABLE_LAYOUT", ""),
},
}),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Unfiltered",
"layout": "table",
"filter": nil,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var view MinimalProjectView
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &view))
assert.Equal(t, "PVTV_nullfilter", view.ID)
assert.Equal(t, "", view.Filter)
})
t.Run("sends explicit empty configuration", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project7"),
Name: githubv4.String("Title only"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
Configuration: &ProjectV2ViewConfigurationInput{
VisibleFieldIDs: []githubv4.ID{},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_empty", 3, "Title only", "TABLE_LAYOUT", "", 101),
},
}),
),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Title only",
"layout": "table",
"visible_fields": []any{},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
assert.Contains(t, getTextResult(t, result).Text, `"visible_fields":[101]`)
})
t.Run("auto-detects an organization owner", func(t *testing.T) {
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{"id": 99, "type": "Organization"}),
})
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 9, "PVT_project9"),
githubv4mock.NewMutationMatcher(
createProjectV2ViewMutation{},
CreateProjectV2ViewInput{
ProjectID: githubv4.ID("PVT_project9"),
Name: githubv4.String("Table"),
Layout: githubv4.ProjectV2ViewLayoutTableLayout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"createProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view3", 3, "Table", "TABLE_LAYOUT", ""),
},
}),
),
)
deps := BaseDeps{Client: mustNewGHClient(t, restClient), GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"project_number": float64(9),
"name": "Table",
"layout": "table",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
})
t.Run("rejects conflicting, unknown, duplicate, and roadmap fields before mutation", func(t *testing.T) {
tests := []struct {
name string
fields []map[string]any
request map[string]any
expectedError string
expectedHint string
}{
{
name: "conflicting identifiers",
request: map[string]any{
"visible_fields": []any{"101"},
"visible_field_names": []any{"Status"},
},
expectedError: "provide either 'visible_fields' or 'visible_field_names'",
},
{
name: "unknown numeric ID",
fields: []map[string]any{statusFieldNode("PVTSSF_status", 101, "Status", nil)},
request: map[string]any{"visible_fields": []any{"202"}},
expectedError: "database ID 202 was not found",
},
{
name: "duplicate name",
fields: []map[string]any{statusFieldNode("PVTSSF_status", 101, "Status", nil)},
request: map[string]any{"visible_field_names": []any{"Status", "status"}},
expectedError: "included more than once",
},
{
name: "unknown name",
fields: []map[string]any{statusFieldNode("PVTSSF_status", 101, "Status", nil)},
request: map[string]any{"visible_field_names": []any{"Priority"}},
expectedError: "field_not_found",
},
{
name: "ambiguous name",
fields: []map[string]any{
statusFieldNode("PVTSSF_status1", 101, "Status", nil),
statusFieldNode("PVTSSF_status2", 202, "Status", nil),
},
request: map[string]any{"visible_field_names": []any{"Status"}},
expectedError: "field_ambiguous",
expectedHint: "visible_fields",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
matchers := []githubv4mock.Matcher{}
if len(tc.fields) > 0 {
matchers = append(matchers, projectFieldNamesMatcher("octo-org", "org", 7, tc.fields))
}
deps := BaseDeps{
Client: emptyRESTClient,
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)),
}
handler := toolDef.Handler(deps)
requestArgs := map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Table",
"layout": "table",
}
maps.Copy(requestArgs, tc.request)
request := createMCPRequest(requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, tc.expectedError)
if tc.expectedHint != "" {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Contains(t, response["hint"], tc.expectedHint)
assert.NotContains(t, response["hint"], "'fields'")
}
})
}
})
t.Run("rejects roadmap layout before resolving visible field names", func(t *testing.T) {
gqlClient, requests := countingGraphQLClient(
projectFieldNamesMatcher("octo-org", "org", 7, []map[string]any{statusFieldNode("PVTSSF_status", 101, "Status", nil)}),
)
deps := BaseDeps{Client: emptyRESTClient, GQLClient: githubv4.NewClient(gqlClient)}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Timeline",
"layout": "roadmap",
"visible_field_names": []any{"Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "visible fields are not supported for roadmap views")
assert.Zero(t, requests(), "expected no field-listing GraphQL request")
})
}
func Test_ProjectsWrite_UpdateProjectView(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
t.Run("updates only the supplied name", func(t *testing.T) {
name := githubv4.String("Renamed")
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Name: &name,
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view1", 1, "Renamed", "TABLE_LAYOUT", "status:Ready", 101, 202),
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"name": "Renamed",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, `"name":"Renamed"`)
assert.Contains(t, getTextResult(t, result).Text, `"visible_fields":[101,202]`)
})
t.Run("replaces and reorders visible fields by database ID", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octo-org", "org", 7, []map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", nil),
multiSelectFieldNode("PVTMSSF_teams", 202, "Teams"),
}),
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
updateProjectV2ViewMutation{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Configuration: &ProjectV2ViewConfigurationInput{
VisibleFieldIDs: []githubv4.ID{"PVTMSSF_teams", "PVTSSF_status"},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view1", 1, "Ready work", "TABLE_LAYOUT", "", 202, 101),
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"visible_fields": []any{"202", "101"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
assert.Contains(t, getTextResult(t, result).Text, `"visible_fields":[202,101]`)
})
t.Run("sends explicit empty visible fields to reset", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
updateProjectV2ViewMutation{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Configuration: &ProjectV2ViewConfigurationInput{
VisibleFieldIDs: []githubv4.ID{},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": projectViewResponse("PVTV_view1", 1, "Ready work", "TABLE_LAYOUT", "", 101),
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"visible_field_names": []any{},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
assert.Contains(t, getTextResult(t, result).Text, `"visible_fields":[101]`)
})
t.Run("rejects nonempty visible fields on an existing roadmap", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octo-org", "org", 7, []map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", nil),
}),
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
githubv4mock.NewQueryMatcher(
projectViewParentQuery{},
map[string]any{"id": githubv4.ID("PVTV_roadmap")},
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": "PVTV_roadmap",
"layout": "ROADMAP_LAYOUT",
"project": map[string]any{"id": "PVT_project7"},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_roadmap",
"visible_field_names": []any{"Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "visible fields are not supported for roadmap views")
})
t.Run("sends null filter to clear it", func(t *testing.T) {
filter := githubv4.String("")
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Filter: &filter,
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": map[string]any{
"id": "PVTV_view1",
"number": 1,
"name": "Renamed",
"layout": "TABLE_LAYOUT",
"filter": "",
},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"filter": nil,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, `"filter":""`)
})
t.Run("rejects an empty string filter", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"filter": "",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "must not be empty")
})
t.Run("normalizes an updated layout to the GraphQL enum", func(t *testing.T) {
layout := githubv4.ProjectV2ViewLayoutBoardLayout
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Layout: &layout,
},
nil,
githubv4mock.DataResponse(map[string]any{
"updateProjectV2View": map[string]any{
"projectV2View": map[string]any{
"id": "PVTV_view1",
"number": 1,
"name": "Board",
"layout": "BOARD_LAYOUT",
"filter": "",
},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"layout": "board",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, `"layout":"board"`)
})
t.Run("surfaces GraphQL API errors", func(t *testing.T) {
name := githubv4.String("Renamed")
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}{},
UpdateProjectV2ViewInput{
ViewID: githubv4.ID("PVTV_view1"),
Name: &name,
},
nil,
githubv4mock.ErrorResponse("update failed"),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"name": "Renamed",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, ProjectViewUpdateFailedError)
assert.Contains(t, getTextResult(t, result).Text, "update failed")
})
for _, tc := range []struct {
name string
owner string
ownerType string
resolveReq githubv4mock.Matcher
}{
{
name: "rejects organization project mismatch",
owner: "octo-org",
ownerType: "org",
resolveReq: resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_org_project"),
},
{
name: "rejects user project mismatch",
owner: "octocat",
ownerType: "user",
resolveReq: resolveProjectNodeIDUserMatcher("octocat", 7, "PVT_user_project"),
},
} {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
tc.resolveReq,
projectViewParentMatcher("PVTV_view1", "PVT_other_project"),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": tc.owner,
"owner_type": tc.ownerType,
"project_number": float64(7),
"view_id": "PVTV_view1",
"name": "Renamed",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, ProjectViewUpdateFailedError)
assert.Contains(t, getTextResult(t, result).Text, "project view does not belong to the requested project")
})
}
t.Run("surfaces parent verification API errors", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentErrorMatcher("PVTV_view1", "lookup failed"),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
"name": "Renamed",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, ProjectViewUpdateFailedError)
assert.Contains(t, getTextResult(t, result).Text, "failed to resolve project view: lookup failed")
})
t.Run("rejects an empty update", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "requires at least one of name, layout, filter, visible_fields, or visible_field_names")
})
}
func Test_ProjectsWrite_DeleteProjectView(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
t.Run("deletes a view from the requested project", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentMatcher("PVTV_view1", "PVT_project7"),
githubv4mock.NewMutationMatcher(
struct {
DeleteProjectV2View struct {
ProjectV2View struct {
ID githubv4.ID
} `graphql:"projectV2View"`
} `graphql:"deleteProjectV2View(input: $input)"`
}{},
DeleteProjectV2ViewInput{ViewID: githubv4.ID("PVTV_view1")},
nil,
githubv4mock.DataResponse(map[string]any{
"deleteProjectV2View": map[string]any{
"projectV2View": map[string]any{"id": "PVTV_view1"},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "delete_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.JSONEq(t, `{"deleted_view_id":"PVTV_view1"}`, getTextResult(t, result).Text)
})
for _, tc := range []struct {
name string
owner string
ownerType string
resolveReq githubv4mock.Matcher
}{
{
name: "rejects organization project mismatch",
owner: "octo-org",
ownerType: "org",
resolveReq: resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_org_project"),
},
{
name: "rejects user project mismatch",
owner: "octocat",
ownerType: "user",
resolveReq: resolveProjectNodeIDUserMatcher("octocat", 7, "PVT_user_project"),
},
} {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
tc.resolveReq,
projectViewParentMatcher("PVTV_view1", "PVT_other_project"),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "delete_project_view",
"owner": tc.owner,
"owner_type": tc.ownerType,
"project_number": float64(7),
"view_id": "PVTV_view1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, ProjectViewDeleteFailedError)
assert.Contains(t, getTextResult(t, result).Text, "project view does not belong to the requested project")
})
}
t.Run("surfaces parent verification API errors", func(t *testing.T) {
gqlClient := githubv4mock.NewMockedHTTPClient(
resolveProjectNodeIDOrgMatcher("octo-org", 7, "PVT_project7"),
projectViewParentErrorMatcher("PVTV_view1", "lookup failed"),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlClient),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "delete_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"view_id": "PVTV_view1",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, ProjectViewDeleteFailedError)
assert.Contains(t, getTextResult(t, result).Text, "failed to resolve project view: lookup failed")
})
}