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
This commit is contained in:
Bryan Zwicker
2026-08-12 09:40:51 -04:00
committed by GitHub
parent d6cab9757a
commit 2198e8599b
7 changed files with 902 additions and 463 deletions
+2 -2
View File
@@ -1141,8 +1141,8 @@ The following sets of tools are available:
- `title`: The project title. Required for 'create_project' method. (string, optional)
- `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID. (object, optional)
- `view_id`: Project view node ID for update or delete; must belong to owner/project_number. (string, optional)
- `visible_field_names`: Field names for table or board creation; mutually exclusive with visible_fields. (string[], optional)
- `visible_fields`: Field database IDs for table or board creation; mutually exclusive with visible_field_names. (string[], optional)
- `visible_field_names`: Ordered project field names to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_fields. Roadmap accepts only []. (string[], optional)
- `visible_fields`: Ordered project field database IDs to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_field_names. Roadmap accepts only []. (string[], optional)
</details>
+2 -2
View File
@@ -257,14 +257,14 @@
"type": "string"
},
"visible_field_names": {
"description": "Field names for table or board creation; mutually exclusive with visible_fields.",
"description": "Ordered project field names to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_fields. Roadmap accepts only [].",
"items": {
"type": "string"
},
"type": "array"
},
"visible_fields": {
"description": "Field database IDs for table or board creation; mutually exclusive with visible_field_names.",
"description": "Ordered project field database IDs to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_field_names. Roadmap accepts only [].",
"items": {
"type": "string"
},
+1 -1
View File
@@ -439,7 +439,7 @@ type MinimalProjectView struct {
Name string `json:"name"`
Layout string `json:"layout"`
Filter string `json:"filter"`
VisibleFields []int64 `json:"visible_fields,omitempty"`
VisibleFields []int64 `json:"visible_fields"`
}
type MinimalProjectItem struct {
+248 -137
View File
@@ -121,11 +121,35 @@ type statusUpdateNodeQuery struct {
}
type projectViewNode struct {
ID githubv4.ID
Number githubv4.Int
Name githubv4.String
Layout githubv4.ProjectV2ViewLayout
Filter *githubv4.String
ID githubv4.ID
Number githubv4.Int
Name githubv4.String
Layout githubv4.ProjectV2ViewLayout
Filter *githubv4.String
Configuration projectViewConfiguration
}
type projectViewConfiguration struct {
VisibleFields projectViewVisibleFieldsConnection `graphql:"visibleFields(first: 100)"`
}
type projectViewVisibleFieldsConnection struct {
Nodes []projectViewVisibleFieldNode
}
type projectViewVisibleFieldNode struct {
ProjectV2Field struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2IterationField"`
ProjectV2MultiSelectField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2MultiSelectField"`
ProjectV2SingleSelectField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2SingleSelectField"`
}
type projectViewNodeWithProject struct {
@@ -166,6 +190,7 @@ type projectViewParentQuery struct {
Node struct {
ProjectView struct {
ID githubv4.ID
Layout githubv4.ProjectV2ViewLayout
Project struct {
ID githubv4.ID
}
@@ -173,29 +198,38 @@ type projectViewParentQuery struct {
} `graphql:"node(id: $id)"`
}
// CreateProjectV2ViewRequest is the REST request for creating a project view.
type CreateProjectV2ViewRequest struct {
Name string `json:"name"`
Layout string `json:"layout"`
Filter *string `json:"filter,omitempty"`
VisibleFields []int64 `json:"visible_fields,omitempty"`
// ProjectV2ViewConfigurationInput is the GraphQL view configuration input.
type ProjectV2ViewConfigurationInput struct {
VisibleFieldIDs []githubv4.ID `json:"visibleFieldIds"`
}
type projectV2ViewRESTResponse struct {
NodeID string `json:"node_id"`
Number int `json:"number"`
Name string `json:"name"`
Layout string `json:"layout"`
Filter *string `json:"filter,omitempty"`
VisibleFields []int64 `json:"visible_fields,omitempty"`
// CreateProjectV2ViewInput is the GraphQL input for creating a project view.
type CreateProjectV2ViewInput struct {
ProjectID githubv4.ID `json:"projectId"`
Name githubv4.String `json:"name"`
Layout githubv4.ProjectV2ViewLayout `json:"layout"`
Configuration *ProjectV2ViewConfigurationInput `json:"configuration,omitempty"`
}
// UpdateProjectV2ViewInput is the GraphQL input for updating a project view.
type UpdateProjectV2ViewInput struct {
ViewID githubv4.ID `json:"viewId"`
Name *githubv4.String `json:"name,omitempty"`
Layout *githubv4.ProjectV2ViewLayout `json:"layout,omitempty"`
Filter *githubv4.String `json:"filter,omitempty"`
ViewID githubv4.ID `json:"viewId"`
Name *githubv4.String `json:"name,omitempty"`
Layout *githubv4.ProjectV2ViewLayout `json:"layout,omitempty"`
Filter *githubv4.String `json:"filter,omitempty"`
Configuration *ProjectV2ViewConfigurationInput `json:"configuration,omitempty"`
}
type createProjectV2ViewMutation struct {
CreateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"createProjectV2View(input: $input)"`
}
type updateProjectV2ViewMutation struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}
// DeleteProjectV2ViewInput is the GraphQL input for deleting a project view.
@@ -761,14 +795,14 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"visible_fields": {
Type: "array",
Description: "Field database IDs for table or board creation; mutually exclusive with visible_field_names.",
Description: "Ordered project field database IDs to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_field_names. Roadmap accepts only [].",
Items: &jsonschema.Schema{
Type: "string",
},
},
"visible_field_names": {
Type: "array",
Description: "Field names for table or board creation; mutually exclusive with visible_fields.",
Description: "Ordered project field names to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_fields. Roadmap accepts only [].",
Items: &jsonschema.Schema{
Type: "string",
},
@@ -900,21 +934,6 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
}
}
if method == projectsMethodCreateProjectView {
visibleFieldNames, namesErr := OptionalStringArrayParam(args, "visible_field_names")
if namesErr != nil {
return utils.NewToolResultError(namesErr.Error()), nil, nil
}
var gqlClient *githubv4.Client
if len(visibleFieldNames) > 0 {
gqlClient, err = deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
return createProjectView(ctx, client, gqlClient, args, owner, ownerType, projectNumber, visibleFieldNames)
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -1010,6 +1029,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate)
case projectsMethodCreateIterationField:
return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args)
case projectsMethodCreateProjectView:
return createProjectView(ctx, gqlClient, args, owner, ownerType, projectNumber)
case projectsMethodUpdateProjectView:
return updateProjectView(ctx, gqlClient, args, owner, ownerType, projectNumber)
case projectsMethodDeleteProjectView:
@@ -1860,12 +1881,26 @@ func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, sta
}
func convertToMinimalProjectView(node projectViewNode) MinimalProjectView {
visibleFields := make([]int64, 0, len(node.Configuration.VisibleFields.Nodes))
for _, field := range node.Configuration.VisibleFields.Nodes {
switch {
case field.ProjectV2SingleSelectField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2SingleSelectField.DatabaseID))
case field.ProjectV2MultiSelectField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2MultiSelectField.DatabaseID))
case field.ProjectV2IterationField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2IterationField.DatabaseID))
default:
visibleFields = append(visibleFields, int64(field.ProjectV2Field.DatabaseID))
}
}
return MinimalProjectView{
ID: fmt.Sprintf("%v", node.ID),
Number: int(node.Number),
Name: string(node.Name),
Layout: projectViewLayoutName(node.Layout),
Filter: derefString(node.Filter),
ID: fmt.Sprintf("%v", node.ID),
Number: int(node.Number),
Name: string(node.Name),
Layout: projectViewLayoutName(node.Layout),
Filter: derefString(node.Filter),
VisibleFields: visibleFields,
}
}
@@ -2001,7 +2036,81 @@ func getProjectView(ctx context.Context, gqlClient *githubv4.Client, viewID stri
return utils.NewToolResultText(string(result)), !bool(query.Node.ProjectView.Project.Public), nil, nil
}
func createProjectView(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int, visibleFieldNames []string) (*mcp.CallToolResult, any, error) {
func projectViewVisibleFieldsInput(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*ProjectV2ViewConfigurationInput, error) {
_, hasVisibleFields := args["visible_fields"]
_, hasVisibleFieldNames := args["visible_field_names"]
if !hasVisibleFields && !hasVisibleFieldNames {
return nil, nil
}
databaseIDs, err := OptionalBigIntArrayParam(args, "visible_fields")
if err != nil {
return nil, err
}
names, err := OptionalStringArrayParam(args, "visible_field_names")
if err != nil {
return nil, err
}
if len(databaseIDs) > 0 && len(names) > 0 {
return nil, errors.New("provide either 'visible_fields' or 'visible_field_names', not both")
}
if len(databaseIDs) == 0 && len(names) == 0 {
return &ProjectV2ViewConfigurationInput{VisibleFieldIDs: []githubv4.ID{}}, nil
}
all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return nil, err
}
var resolved []ResolvedField
if len(names) > 0 {
resolved, err = resolveFieldsByName(all, owner, projectNumber, names, "visible_fields")
if err != nil {
return nil, err
}
} else {
byDatabaseID := make(map[int64]ResolvedField, len(all))
for _, field := range all {
id, parseErr := parseInt64(field.ID)
if parseErr != nil {
continue
}
byDatabaseID[id] = field
}
resolved = make([]ResolvedField, 0, len(databaseIDs))
for _, id := range databaseIDs {
field, ok := byDatabaseID[id]
if !ok {
return nil, fmt.Errorf("project field database ID %d was not found on project %s#%d", id, owner, projectNumber)
}
resolved = append(resolved, field)
}
}
nodeIDs := make([]githubv4.ID, 0, len(resolved))
seen := make(map[string]struct{}, len(resolved))
for _, field := range resolved {
if _, ok := seen[field.NodeID]; ok {
return nil, fmt.Errorf("project field %q is included more than once", field.Name)
}
seen[field.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, githubv4.ID(field.NodeID))
}
return &ProjectV2ViewConfigurationInput{VisibleFieldIDs: nodeIDs}, nil
}
// projectViewRequestsVisibleFields reports whether the caller asked for a non-empty
// set of visible fields, without resolving them against the project.
func projectViewRequestsVisibleFields(args map[string]any) bool {
if databaseIDs, err := OptionalBigIntArrayParam(args, "visible_fields"); err == nil && len(databaseIDs) > 0 {
return true
}
names, err := OptionalStringArrayParam(args, "visible_field_names")
return err == nil && len(names) > 0
}
func createProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
name, err := RequiredParam[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -2021,100 +2130,80 @@ func createProjectView(ctx context.Context, client *github.Client, gqlClient *gi
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
visibleFields, err := OptionalBigIntArrayParam(args, "visible_fields")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if len(visibleFields) > 0 && len(visibleFieldNames) > 0 {
return utils.NewToolResultError("provide either 'visible_fields' or 'visible_field_names', not both"), nil, nil
}
if len(visibleFieldNames) > 0 {
resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, visibleFieldNames, "visible_fields")
if resolveErr != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(resolveErr, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(resolveErr.Error()), nil, nil
}
visibleFields = resolvedIDs
}
if layout == githubv4.ProjectV2ViewLayoutRoadmapLayout && len(visibleFields) > 0 {
if layout == githubv4.ProjectV2ViewLayoutRoadmapLayout && projectViewRequestsVisibleFields(args) {
return utils.NewToolResultError("visible fields are not supported for roadmap views"), nil, nil
}
requestBody := CreateProjectV2ViewRequest{
Name: name,
Layout: projectViewLayoutName(layout),
VisibleFields: visibleFields,
}
if hasFilter {
// The API clears a filter with an empty string, so a null filter is sent as "".
value := ""
if filter != nil {
value = *filter
}
requestBody.Filter = &value
}
var endpoint string
switch ownerType {
case "org":
endpoint = fmt.Sprintf("orgs/%s/projectsV2/%d/views", owner, projectNumber)
case "user":
endpoint = fmt.Sprintf("users/%s/projectsV2/%d/views", owner, projectNumber)
default:
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
}
req, err := client.NewRequest(ctx, http.MethodPost, endpoint, requestBody)
configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber)
if err != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(err.Error()), nil, nil
}
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: failed to resolve project: %v", ProjectViewCreateFailedError, err)), nil, nil
}
if projectID == nil || projectID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: project was not found", ProjectViewCreateFailedError)), nil, nil
}
input := CreateProjectV2ViewInput{
ProjectID: projectID,
Name: githubv4.String(name),
Layout: layout,
Configuration: configuration,
}
var mutation createProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewCreateFailedError, err)), nil, nil
}
var response projectV2ViewRESTResponse
resp, err := client.Do(req, &response)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectViewCreateFailedError, resp, err), nil, nil
}
if response.NodeID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: response did not include a project view node ID", ProjectViewCreateFailedError)), nil, nil
view := mutation.CreateProjectV2View.ProjectV2View
if view.ID == nil || view.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: response did not include a project view", ProjectViewCreateFailedError)), nil, nil
}
filterValue := ""
if response.Filter != nil {
filterValue = *response.Filter
if hasFilter && filter != nil {
filterValue := githubv4.String(*filter)
updateInput := UpdateProjectV2ViewInput{
ViewID: githubv4.ID(fmt.Sprintf("%v", view.ID)),
Filter: &filterValue,
}
var updateMutation updateProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &updateMutation, updateInput, nil); err != nil {
cleanupErr := deleteProjectViewByID(ctx, gqlClient, updateInput.ViewID)
if cleanupErr != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: failed to set filter: %v; failed to clean up created view %v: %v", ProjectViewCreateFailedError, err, updateInput.ViewID, cleanupErr)), nil, nil
}
return utils.NewToolResultError(fmt.Sprintf("%s: failed to set filter: %v; created view was cleaned up", ProjectViewCreateFailedError, err)), nil, nil
}
view = updateMutation.UpdateProjectV2View.ProjectV2View
}
view := MinimalProjectView{
ID: response.NodeID,
Number: response.Number,
Name: response.Name,
Layout: projectViewLayoutName(githubv4.ProjectV2ViewLayout(response.Layout)),
Filter: filterValue,
VisibleFields: response.VisibleFields,
}
return MarshalledTextResult(view), nil, nil
return MarshalledTextResult(convertToMinimalProjectView(view)), nil, nil
}
func verifyProjectViewParent(ctx context.Context, gqlClient *githubv4.Client, viewID, owner, ownerType string, projectNumber int) error {
func verifyProjectViewParent(ctx context.Context, gqlClient *githubv4.Client, viewID, owner, ownerType string, projectNumber int) (githubv4.ProjectV2ViewLayout, error) {
expectedProjectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return fmt.Errorf("failed to resolve requested project: %w", err)
return "", fmt.Errorf("failed to resolve requested project: %w", err)
}
if expectedProjectID == nil || expectedProjectID == "" {
return fmt.Errorf("requested project was not found")
return "", fmt.Errorf("requested project was not found")
}
var query projectViewParentQuery
if err := gqlClient.Query(ctx, &query, map[string]any{"id": githubv4.ID(viewID)}); err != nil {
return fmt.Errorf("failed to resolve project view: %w", err)
return "", fmt.Errorf("failed to resolve project view: %w", err)
}
if query.Node.ProjectView.ID == nil || query.Node.ProjectView.ID == "" {
return fmt.Errorf("node is not a ProjectV2View or was not found")
return "", fmt.Errorf("node is not a ProjectV2View or was not found")
}
if query.Node.ProjectView.Project.ID != expectedProjectID {
return fmt.Errorf("project view does not belong to the requested project")
return "", fmt.Errorf("project view does not belong to the requested project")
}
return nil
return query.Node.ProjectView.Layout, nil
}
func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
@@ -2134,8 +2223,10 @@ func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if !hasName && !hasLayout && !hasFilter {
return utils.NewToolResultError("update_project_view requires at least one of name, layout, or filter"), nil, nil
_, hasVisibleFields := args["visible_fields"]
_, hasVisibleFieldNames := args["visible_field_names"]
if !hasName && !hasLayout && !hasFilter && !hasVisibleFields && !hasVisibleFieldNames {
return utils.NewToolResultError("update_project_view requires at least one of name, layout, filter, visible_fields, or visible_field_names"), nil, nil
}
if hasName && strings.TrimSpace(name) == "" {
return utils.NewToolResultError("name must not be empty"), nil, nil
@@ -2161,15 +2252,29 @@ func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map
}
input.Filter = &value
}
if err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber); err != nil {
currentLayout, err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewUpdateFailedError, err)), nil, nil
}
var mutation struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
effectiveLayout := currentLayout
if input.Layout != nil {
effectiveLayout = *input.Layout
}
if effectiveLayout == githubv4.ProjectV2ViewLayoutRoadmapLayout && projectViewRequestsVisibleFields(args) {
return utils.NewToolResultError("visible fields are not supported for roadmap views"), nil, nil
}
configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber)
if err != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(err.Error()), nil, nil
}
input.Configuration = configuration
var mutation updateProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewUpdateFailedError, err)), nil, nil
}
@@ -2179,15 +2284,8 @@ func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map
return MarshalledTextResult(convertToMinimalProjectView(mutation.UpdateProjectV2View.ProjectV2View)), nil, nil
}
func deleteProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
viewID, err := RequiredParam[string](args, "view_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
}
input := DeleteProjectV2ViewInput{ViewID: githubv4.ID(viewID)}
func deleteProjectViewByID(ctx context.Context, gqlClient *githubv4.Client, viewID githubv4.ID) error {
input := DeleteProjectV2ViewInput{ViewID: viewID}
var mutation struct {
DeleteProjectV2View struct {
ProjectV2View struct {
@@ -2196,13 +2294,26 @@ func deleteProjectView(ctx context.Context, gqlClient *githubv4.Client, args map
} `graphql:"deleteProjectV2View(input: $input)"`
}
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
return err
}
if id := mutation.DeleteProjectV2View.ProjectV2View.ID; id == nil || id == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: response did not include the deleted view", ProjectViewDeleteFailedError)), nil, nil
return errors.New("response did not include the deleted project view")
}
deletedID := fmt.Sprintf("%v", mutation.DeleteProjectV2View.ProjectV2View.ID)
return MarshalledTextResult(map[string]string{"deleted_view_id": deletedID}), nil, nil
return nil
}
func deleteProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
viewID, err := RequiredParam[string](args, "view_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if _, err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
}
if err := deleteProjectViewByID(ctx, gqlClient, githubv4.ID(viewID)); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
}
return MarshalledTextResult(map[string]string{"deleted_view_id": viewID}), nil, nil
}
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
+88 -64
View File
@@ -52,33 +52,40 @@ type projectFieldsQueryUser struct {
} `graphql:"user(login: $owner)"`
}
// projectFieldsConnection is a paginated list of project fields. We select `id`
// to discriminate the union variant and `databaseId` for the numeric ID REST needs.
type projectFieldNode struct {
ProjectV2Field struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2IterationField"`
ProjectV2MultiSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2MultiSelectField"`
ProjectV2SingleSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
Options []struct {
ID githubv4.String
Name githubv4.String
}
} `graphql:"... on ProjectV2SingleSelectField"`
}
// projectFieldsConnection is a paginated list of project fields.
type projectFieldsConnection struct {
Nodes []struct {
ProjectV2Field struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2IterationField"`
ProjectV2SingleSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
Options []struct {
ID githubv4.String
Name githubv4.String
}
} `graphql:"... on ProjectV2SingleSelectField"`
}
Nodes []projectFieldNode
PageInfo PageInfoFragment
}
@@ -134,6 +141,13 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner
Name: string(n.ProjectV2IterationField.Name),
DataType: string(n.ProjectV2IterationField.DataType),
})
case n.ProjectV2MultiSelectField.ID != nil:
all = append(all, ResolvedField{
ID: fmt.Sprintf("%d", n.ProjectV2MultiSelectField.DatabaseID),
NodeID: fmt.Sprintf("%v", n.ProjectV2MultiSelectField.ID),
Name: string(n.ProjectV2MultiSelectField.Name),
DataType: string(n.ProjectV2MultiSelectField.DataType),
})
case n.ProjectV2Field.ID != nil:
all = append(all, ResolvedField{
ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID),
@@ -154,6 +168,46 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner
return all, nil
}
func resolveFieldsByName(all []ResolvedField, owner string, projectNumber int, names []string, idParameter string) ([]ResolvedField, error) {
byName := make(map[string][]ResolvedField, len(all))
for _, field := range all {
key := strings.ToLower(field.Name)
byName[key] = append(byName[key], field)
}
resolved := make([]ResolvedField, 0, len(names))
for _, name := range names {
matches := byName[strings.ToLower(name)]
switch len(matches) {
case 0:
candidates := make([]any, 0, len(all))
for _, field := range all {
candidates = append(candidates, map[string]any{"name": field.Name, "data_type": field.DataType})
}
return nil, ghErrors.NewStructuredResolutionError(
"field_not_found",
name,
fmt.Sprintf("no project field named %q on project %s#%d", name, owner, projectNumber),
candidates,
)
case 1:
resolved = append(resolved, matches[0])
default:
candidates := make([]any, 0, len(matches))
for _, field := range matches {
candidates = append(candidates, map[string]any{"id": field.ID, "data_type": field.DataType})
}
return nil, ghErrors.NewStructuredResolutionError(
"field_ambiguous",
name,
fmt.Sprintf("multiple fields share this name; pass numeric IDs via '%s' to disambiguate", idParameter),
candidates,
)
}
}
return resolved, nil
}
// resolveProjectFieldByName resolves a field by display name. Returns a
// structured error on not-found, ambiguous, or wrong-data-type (when
// expectedDataType is set) so the agent can self-correct.
@@ -544,47 +598,17 @@ func resolveFieldNamesToIDs(ctx context.Context, gqlClient *githubv4.Client, own
}
func resolveFieldNamesToIDsFromFields(all []ResolvedField, names []string, owner string, projectNumber int, idParameter string) ([]int64, error) {
// Build a name -> []ResolvedField map so we can detect duplicates per name.
// Matching is case-insensitive to align with the GraphQL API's behaviour.
byName := make(map[string][]ResolvedField, len(all))
for _, f := range all {
key := strings.ToLower(f.Name)
byName[key] = append(byName[key], f)
resolved, err := resolveFieldsByName(all, owner, projectNumber, names, idParameter)
if err != nil {
return nil, err
}
out := make([]int64, 0, len(names))
for _, name := range names {
matches := byName[strings.ToLower(name)]
switch len(matches) {
case 0:
candidates := make([]any, 0, len(all))
for _, f := range all {
candidates = append(candidates, map[string]any{"name": f.Name, "data_type": f.DataType})
}
return nil, ghErrors.NewStructuredResolutionError(
"field_not_found",
name,
fmt.Sprintf("no project field named %q on project %s#%d", name, owner, projectNumber),
candidates,
)
case 1:
id, parseErr := parseInt64(matches[0].ID)
if parseErr != nil {
return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via '%s' instead", name, matches[0].ID, idParameter)
}
out = append(out, id)
default:
candidates := make([]any, 0, len(matches))
for _, f := range matches {
candidates = append(candidates, map[string]any{"id": f.ID, "data_type": f.DataType})
}
return nil, ghErrors.NewStructuredResolutionError(
"field_ambiguous",
name,
fmt.Sprintf("multiple fields share this name; pass numeric IDs via '%s' to disambiguate", idParameter),
candidates,
)
for i, field := range resolved {
id, parseErr := parseInt64(field.ID)
if parseErr != nil {
return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via '%s' instead", names[i], field.ID, idParameter)
}
out = append(out, id)
}
return out, nil
}
+17
View File
@@ -34,6 +34,12 @@ type projectFieldsTestQuery struct {
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2IterationField"`
ProjectV2MultiSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2MultiSelectField"`
ProjectV2SingleSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
@@ -94,6 +100,15 @@ func genericFieldNode(nodeID string, databaseID int, name, dataType string) map[
}
}
func multiSelectFieldNode(nodeID string, databaseID int, name string) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"name": name,
"dataType": "MULTI_SELECT",
}
}
func fieldsResponse(nodes []map[string]any) map[string]any {
return map[string]any{
"organization": map[string]any{
@@ -264,6 +279,7 @@ func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) {
{"id": "OPT_a", "name": "Todo"},
}),
iterationFieldNode("PVTIF_iteration1", 222, "Sprint"),
multiSelectFieldNode("PVTMSSF_multi1", 444, "Teams"),
genericFieldNode("PVTF_text1", 333, "Notes", "TEXT"),
})),
),
@@ -277,6 +293,7 @@ func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) {
}{
{"Status", "SINGLE_SELECT", "PVTSSF_single1"},
{"Sprint", "ITERATION", "PVTIF_iteration1"},
{"Teams", "MULTI_SELECT", "PVTMSSF_multi1"},
{"Notes", "TEXT", "PVTF_text1"},
}
for _, v := range variants {
+544 -257
View File
@@ -3,7 +3,9 @@ package github
import (
"context"
"encoding/json"
"maps"
"net/http"
"sync/atomic"
"testing"
"time"
@@ -195,6 +197,7 @@ func projectViewParentMatcher(viewID, projectID string) githubv4mock.Matcher {
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": viewID,
"layout": "TABLE_LAYOUT",
"project": map[string]any{"id": projectID},
},
}),
@@ -209,6 +212,24 @@ func projectViewParentErrorMatcher(viewID, message string) githubv4mock.Matcher
)
}
// 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" {
@@ -242,6 +263,23 @@ func projectFieldNamesMatcher(owner, ownerType string, projectNumber int, nodes
)
}
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 {
@@ -562,6 +600,11 @@ func Test_ProjectsList_ListProjectViews(t *testing.T) {
"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{
@@ -606,11 +649,12 @@ func Test_ProjectsList_ListProjectViews(t *testing.T) {
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",
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)
@@ -717,6 +761,11 @@ func Test_ProjectsGet_GetProjectView(t *testing.T) {
"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}},
},
},
},
}),
),
@@ -739,6 +788,7 @@ func Test_ProjectsGet_GetProjectView(t *testing.T) {
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"])
@@ -768,145 +818,85 @@ func Test_ProjectsGet_GetProjectView(t *testing.T) {
func Test_ProjectsWrite_CreateProjectView(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
emptyRESTClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
t.Run("creates organization view with filter and visible fields", func(t *testing.T) {
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"POST /orgs/{org}/projectsV2/{project}/views": func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/orgs/octo-org/projectsV2/7/views", r.URL.Path)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "Ready work", body["name"])
assert.Equal(t, "table", body["layout"])
assert.Equal(t, "status:Ready", body["filter"])
assert.Equal(t, []any{float64(101), float64(202)}, body["visible_fields"])
mockResponse(t, http.StatusCreated, map[string]any{
"node_id": "PVTV_view1",
"number": 1,
"name": "Ready work",
"layout": "table",
"filter": "status:Ready",
"visible_fields": []int64{101, 202},
})(w, r)
},
})
deps := BaseDeps{Client: mustNewGHClient(t, restClient)}
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_fields": []any{"101", "202"},
})
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, []int64{101, 202}, view.VisibleFields)
})
t.Run("resolves organization visible field names in caller order", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
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),
statusFieldNode("PVTSSF_priority", 202, "Priority", nil),
multiSelectFieldNode("PVTMSSF_teams", 202, "Teams"),
}),
))
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"POST /orgs/{org}/projectsV2/{project}/views": func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, []any{float64(202), float64(101)}, body["visible_fields"])
mockResponse(t, http.StatusCreated, map[string]any{
"node_id": "PVTV_named_org",
"number": 2,
"name": "Named fields",
"layout": "table",
"visible_fields": []int64{202, 101},
})(w, r)
},
})
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
}
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": "Named fields",
"name": "Ready work",
"layout": "table",
"visible_field_names": []any{"Priority", "status"},
"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)
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("resolves user visible field names", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octocat", "user", 8, []map[string]any{
statusFieldNode("PVTSSF_status", 303, "Status", nil),
}),
))
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"POST /users/{user_id}/projectsV2/{project}/views": func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/users/octocat/projectsV2/8/views", r.URL.Path)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, []any{float64(303)}, body["visible_fields"])
mockResponse(t, http.StatusCreated, map[string]any{
"node_id": "PVTV_named_user",
"number": 3,
"name": "User fields",
"layout": "board",
"visible_fields": []int64{303},
})(w, r)
},
})
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octocat",
"owner_type": "user",
"project_number": float64(8),
"name": "User fields",
"layout": "board",
"visible_field_names": []any{"Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
})
t.Run("creates a user view by login", func(t *testing.T) {
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"POST /users/{user_id}/projectsV2/{project}/views": func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/users/octocat/projectsV2/8/views", r.URL.Path)
mockResponse(t, http.StatusCreated, map[string]any{
"node_id": "PVTV_view2",
"number": 2,
"name": "Board",
"layout": "board",
})(w, r)
},
})
deps := BaseDeps{Client: mustNewGHClient(t, restClient)}
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",
@@ -919,24 +909,229 @@ func Test_ProjectsWrite_CreateProjectView(t *testing.T) {
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, `"id":"PVTV_view2"`)
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",
}),
"POST /orgs/{org}/projectsV2/{project}/views": mockResponse(t, http.StatusCreated, map[string]any{
"node_id": "PVTV_view3",
"number": 3,
"name": "Table",
"layout": "table",
}),
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{"id": 99, "type": "Organization"}),
})
deps := BaseDeps{Client: mustNewGHClient(t, restClient)}
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",
@@ -948,127 +1143,101 @@ func Test_ProjectsWrite_CreateProjectView(t *testing.T) {
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.False(t, result.IsError, getTextResult(t, result).Text)
})
t.Run("rejects visible fields and names together", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
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",
},
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Table",
"layout": "table",
"visible_fields": []any{"101"},
"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, "provide either 'visible_fields' or 'visible_field_names', not both")
})
for _, tc := range []struct {
name string
nodes []map[string]any
requestedName string
expectedError string
expectedHint string
}{
{
name: "returns structured not-found errors",
nodes: []map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", nil),
},
requestedName: "Priority",
expectedError: "field_not_found",
},
{
name: "returns structured ambiguous errors",
nodes: []map[string]any{
statusFieldNode("PVTSSF_status1", 101, "Status", nil),
statusFieldNode("PVTSSF_status2", 202, "Status", nil),
},
requestedName: "Status",
expectedError: "field_ambiguous",
expectedHint: "visible_fields",
},
} {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octo-org", "org", 7, tc.nodes),
))
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: 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": "Table",
"layout": "table",
"visible_field_names": []any{tc.requestedName},
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'")
}
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, tc.expectedError, response["error"])
assert.Equal(t, tc.requestedName, response["name"])
if tc.expectedHint != "" {
assert.Contains(t, response["hint"], tc.expectedHint)
assert.NotContains(t, response["hint"], "'fields'")
}
})
}
t.Run("rejects visible fields for roadmap layout", func(t *testing.T) {
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "create_project_view",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(7),
"name": "Roadmap",
"layout": "roadmap",
"visible_fields": []any{"101"},
})
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("resolves visible field names before rejecting roadmap layout", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
projectFieldNamesMatcher("octo-org", "org", 7, []map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", nil),
}),
))
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: gqlClient,
}
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": "Roadmap",
"name": "Timeline",
"layout": "roadmap",
"visible_field_names": []any{"Status"},
})
@@ -1077,6 +1246,7 @@ func Test_ProjectsWrite_CreateProjectView(t *testing.T) {
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")
})
}
@@ -1101,13 +1271,7 @@ func Test_ProjectsWrite_UpdateProjectView(t *testing.T) {
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": "status:Ready",
},
"projectV2View": projectViewResponse("PVTV_view1", 1, "Renamed", "TABLE_LAYOUT", "status:Ready", 101, 202),
},
}),
),
@@ -1130,6 +1294,129 @@ func Test_ProjectsWrite_UpdateProjectView(t *testing.T) {
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) {
@@ -1380,7 +1667,7 @@ func Test_ProjectsWrite_UpdateProjectView(t *testing.T) {
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, or filter")
assert.Contains(t, getTextResult(t, result).Text, "requires at least one of name, layout, filter, visible_fields, or visible_field_names")
})
}