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

1064 lines
34 KiB
Go

package github
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/http/headers"
transportpkg "github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// projectFieldsQueryMatcher is the GraphQL shape we use for fields(first:100) resolution.
// Keep this in sync with projectFieldsConnection in projects_resolver.go.
type projectFieldsTestQuery struct {
Organization struct {
ProjectV2 struct {
Fields 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"`
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"`
}
PageInfo PageInfoFragment
} `graphql:"fields(first: $first, after: $after)"`
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}
func fieldsQueryVars(owner string, projectNumber int) map[string]any {
return map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec
"first": githubv4.Int(resolverFieldsPageSize),
"after": (*githubv4.String)(nil),
}
}
// statusFieldNode is a single-select field response node for use in mock data.
// `nodeID` is the global node ID (e.g. "PVTSSF_lADO...") and `databaseID` is
// the numeric database ID the REST API expects.
func statusFieldNode(nodeID string, databaseID int, name string, options []map[string]any) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"name": name,
"dataType": "SINGLE_SELECT",
"options": options,
}
}
// iterationFieldNode is an iteration field response node for use in mock data.
func iterationFieldNode(nodeID string, databaseID int, name string) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"name": name,
"dataType": "ITERATION",
}
}
// genericFieldNode is a plain field response node (neither single-select nor
// iteration, e.g. TEXT or NUMBER) for use in mock data.
func genericFieldNode(nodeID string, databaseID int, name, dataType string) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"name": name,
"dataType": dataType,
}
}
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{
"projectV2": map[string]any{
"fields": map[string]any{
"nodes": nodes,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}
}
func Test_ResolveProjectFieldByName_Success(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{
{"id": "OPT_a", "name": "Todo"},
{"id": "OPT_b", "name": "In Progress"},
{"id": "OPT_c", "name": "Done"},
}),
})),
),
)
gql := githubv4.NewClient(mocked)
field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT")
require.NoError(t, err)
require.NotNil(t, field)
assert.Equal(t, "12345", field.ID)
assert.Equal(t, "PVTSSF_lADOBBcDeFg123", field.NodeID)
assert.Equal(t, "SINGLE_SELECT", field.DataType)
assert.Len(t, field.Options, 3)
optionID, err := resolveSingleSelectOptionByName(field, "In Progress")
require.NoError(t, err)
assert.Equal(t, "OPT_b", optionID)
}
func Test_ResolveIssueFieldForUpdate(t *testing.T) {
tests := []struct {
name string
resolved ResolvedField
databaseID int
typeName string
issueField map[string]any
wantID string
wantOption ResolvedFieldOption
}{
{name: "text", resolved: ResolvedField{ID: "101", Name: "Customer", DataType: "TEXT"}, databaseID: 101, typeName: "ProjectV2Field", issueField: map[string]any{"id": "IF_TEXT"}, wantID: "IF_TEXT"},
{
name: "single select", resolved: ResolvedField{ID: "102", Name: "Impact", DataType: "SINGLE_SELECT"},
databaseID: 102, typeName: "ProjectV2SingleSelectField",
issueField: map[string]any{"id": "IF_SELECT", "options": []any{map[string]any{"id": "OPT_HIGH", "name": "High"}}},
wantID: "IF_SELECT",
wantOption: ResolvedFieldOption{ID: "OPT_HIGH", Name: "High"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(projectIssueFieldMetadataQueryOrg{}, fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(issueFieldMetadataResponse(tt.typeName, tt.databaseID, true, tt.issueField))),
)
capture := &headerCaptureTransport{inner: mocked.Transport}
gql := githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: capture}})
field, err := resolveIssueFieldForUpdate(context.Background(), gql, "octo-org", "org", 7, &tt.resolved)
require.NoError(t, err)
assert.True(t, field.IsIssueField)
assert.Equal(t, tt.wantID, field.IssueFieldID)
if tt.wantOption.ID != "" {
assert.Equal(t, []ResolvedFieldOption{tt.wantOption}, field.Options)
}
assert.Equal(t, "issue_fields", capture.captured.Get(headers.GraphQLFeaturesHeader))
})
}
}
func Test_ResolveIssueFieldForUpdate_ErrorHandling(t *testing.T) {
for _, tt := range []struct {
name, message string
fallback bool
}{
{name: "missing schema falls back", message: "Field 'isIssueField' doesn't exist on type 'ProjectV2Field'", fallback: true},
{name: "missing text fragment type falls back", message: "No such type IssueFieldText, so it cannot be a fragment condition", fallback: true},
{name: "missing number fragment type falls back", message: "No such type IssueFieldNumber, so it cannot be a fragment condition", fallback: true},
{name: "missing date fragment type falls back", message: "No such type IssueFieldDate, so it cannot be a fragment condition", fallback: true},
{name: "missing single select fragment type falls back", message: "No such type IssueFieldSingleSelect, so it cannot be a fragment condition", fallback: true},
{name: "unknown fragment type propagates", message: "No such type IssueFieldMultiSelect, so it cannot be a fragment condition"},
{name: "unrelated error propagates", message: "Resource not accessible by integration"},
} {
t.Run(tt.name, func(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(
projectIssueFieldMetadataQueryOrg{}, fieldsQueryVars("octo-org", 7), githubv4mock.ErrorResponse(tt.message),
))
resolved := &ResolvedField{ID: "101", Name: "Status", DataType: "SINGLE_SELECT"}
field, err := resolveIssueFieldForUpdate(context.Background(), githubv4.NewClient(mocked), "octo-org", "org", 7, resolved)
if tt.fallback {
require.NoError(t, err)
assert.Equal(t, resolved, field)
} else {
require.ErrorContains(t, err, tt.message)
}
})
}
t.Run("supported type missing metadata still fails", func(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(
projectIssueFieldMetadataQueryOrg{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse(nil)),
))
resolved := &ResolvedField{ID: "101", Name: "Customer", DataType: "TEXT"}
_, err := resolveIssueFieldForUpdate(context.Background(), githubv4.NewClient(mocked), "octo-org", "org", 7, resolved)
require.ErrorContains(t, err, "missing_field_metadata")
})
}
func Test_ResolveFieldNamesToIDs_QueryRemainsIssueFieldUngated(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
genericFieldNode("PVTF_text", 101, "Customer", "TEXT"),
})),
),
)
capture := &headerCaptureTransport{inner: mocked.Transport}
gql := githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: capture}})
ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Customer"}, "fields")
require.NoError(t, err)
assert.Equal(t, []int64{101}, ids)
assert.Empty(t, capture.captured.Get(headers.GraphQLFeaturesHeader))
}
func issueFieldMetadataResponse(typeName string, databaseID any, isIssueField bool, issueField map[string]any) map[string]any {
node := map[string]any{
"__typename": typeName,
"databaseId": databaseID,
"isIssueField": isIssueField,
}
if issueField != nil {
node["issueField"] = issueField
}
return fieldsResponse([]map[string]any{node})
}
func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_single1", 111, "Status", []map[string]any{
{"id": "OPT_a", "name": "Todo"},
}),
iterationFieldNode("PVTIF_iteration1", 222, "Sprint"),
multiSelectFieldNode("PVTMSSF_multi1", 444, "Teams"),
genericFieldNode("PVTF_text1", 333, "Notes", "TEXT"),
})),
),
)
gql := githubv4.NewClient(mocked)
variants := []struct {
fieldName string
expectedType string
wantNodeID string
}{
{"Status", "SINGLE_SELECT", "PVTSSF_single1"},
{"Sprint", "ITERATION", "PVTIF_iteration1"},
{"Teams", "MULTI_SELECT", "PVTMSSF_multi1"},
{"Notes", "TEXT", "PVTF_text1"},
}
for _, v := range variants {
t.Run(v.fieldName, func(t *testing.T) {
field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, v.fieldName, v.expectedType)
require.NoError(t, err)
require.NotNil(t, field)
assert.Equal(t, v.wantNodeID, field.NodeID)
assert.Equal(t, v.expectedType, field.DataType)
})
}
}
func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil),
})),
),
)
gql := githubv4.NewClient(mocked)
_, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "")
require.Error(t, err)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "field_not_found", msg["error"])
assert.Equal(t, "Priority", msg["name"])
assert.NotEmpty(t, msg["candidates"])
}
func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg678", 67890, "Status", nil),
})),
),
)
gql := githubv4.NewClient(mocked)
_, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "")
require.Error(t, err)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "field_ambiguous", msg["error"])
candidates, _ := msg["candidates"].([]any)
assert.Len(t, candidates, 2)
}
func Test_ResolveSingleSelectOptionByName_NotFound(t *testing.T) {
field := &ResolvedField{
ID: "12345",
Name: "Status",
DataType: "SINGLE_SELECT",
Options: []ResolvedFieldOption{
{ID: "OPT_a", Name: "Todo"},
{ID: "OPT_b", Name: "Done"},
},
}
_, err := resolveSingleSelectOptionByName(field, "Blocked")
require.Error(t, err)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "option_not_found", msg["error"])
assert.Equal(t, "Blocked", msg["name"])
}
func Test_ResolveSingleSelectOptionByName_WrongFieldType(t *testing.T) {
field := &ResolvedField{
ID: "12345",
Name: "Description",
DataType: "TEXT",
}
_, err := resolveSingleSelectOptionByName(field, "anything")
require.Error(t, err)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "wrong_field_type", msg["error"])
}
// resolveItemByIssueQuery matches the GraphQL shape used by
// resolveProjectItemIDByIssueNumber for the issue.projectItems traversal.
type resolveItemByIssueQuery struct {
Repository struct {
Issue struct {
ProjectItems struct {
Nodes []struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Project struct {
ID githubv4.ID
}
}
PageInfo PageInfoFragment
} `graphql:"projectItems(first: 50, includeArchived: true)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $issueOwner, name: $issueRepo)"`
}
type resolveItemByIssuePageQuery struct {
Repository struct {
Issue struct {
ProjectItems struct {
Nodes []struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Project struct {
ID githubv4.ID
}
}
PageInfo PageInfoFragment
} `graphql:"projectItems(first: 50, after: $after, includeArchived: true)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $issueOwner, name: $issueRepo)"`
}
type requestCountingTransport struct {
inner http.RoundTripper
count int
}
func (t *requestCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.count++
return t.inner.RoundTrip(req)
}
func Test_ResolveProjectItemByIssueNumber_Success(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
// project node id lookup (org)
githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"id": "PVT_project1",
},
},
}),
),
// issue.projectItems lookup
githubv4mock.NewQueryMatcher(
resolveItemByIssueQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "9999",
"project": map[string]any{"id": "PVT_other"},
},
map[string]any{
"id": "PVTI_target",
"fullDatabaseId": "4242",
"project": map[string]any{"id": "PVT_project1"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}),
),
)
gql := githubv4.NewClient(mocked)
nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
require.NoError(t, err)
assert.Equal(t, "PVTI_target", nodeID)
assert.Equal(t, int64(4242), itemID)
}
func Test_ResolveProjectItemByIssueNumber_TargetOnSecondPage(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{"id": "PVT_project1"},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssueQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "9999",
"project": map[string]any{"id": "PVT_other"},
},
},
"pageInfo": map[string]any{
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "first",
"endCursor": "page-one",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssuePageQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"after": githubv4.String("page-one"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"id": "PVTI_target",
"fullDatabaseId": "4242",
"project": map[string]any{"id": "PVT_project1"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": true,
"startCursor": "page-two",
"endCursor": "page-two",
},
},
},
},
}),
),
)
gql := githubv4.NewClient(mocked)
nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
require.NoError(t, err)
assert.Equal(t, "PVTI_target", nodeID)
assert.Equal(t, int64(4242), itemID)
}
func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"id": "PVT_project1",
},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssueQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "9999",
"project": map[string]any{"id": "PVT_other"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}),
),
)
gql := githubv4.NewClient(mocked)
_, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
require.Error(t, err)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "item_not_in_project", msg["error"])
}
func Test_ResolveProjectItemIDByIssueNumber_NotInProjectAfterMultiplePages(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{"id": "PVT_project1"},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssueQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "9999",
"project": map[string]any{"id": "PVT_other"},
},
},
"pageInfo": map[string]any{
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "first",
"endCursor": "page-one",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssuePageQuery{},
map[string]any{
"issueOwner": githubv4.String("octo-issue-owner"),
"issueRepo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"after": githubv4.String("page-one"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "8888",
"project": map[string]any{"id": "PVT_another"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": true,
"startCursor": "page-two",
"endCursor": "page-two",
},
},
},
},
}),
),
)
countingTransport := &requestCountingTransport{inner: mocked.Transport}
mocked.Transport = countingTransport
gql := githubv4.NewClient(mocked)
_, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123)
require.Error(t, err)
assert.Equal(t, 3, countingTransport.count)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg))
assert.Equal(t, "item_not_in_project", msg["error"])
}
func Test_ResolveFieldNamesToIDs_Success(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil),
})),
),
)
gql := githubv4.NewClient(mocked)
ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Status", "Priority"}, "fields")
require.NoError(t, err)
assert.Equal(t, []int64{100, 200}, ids)
}
// Field and single-select option name matching is case-insensitive so agents passing lowercase
// names like "status" or "in progress" resolve to "Status" and "In Progress" respectively.
func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{
{"id": "OPT_a", "name": "Todo"},
{"id": "OPT_b", "name": "In Progress"},
}),
})),
),
)
gql := githubv4.NewClient(mocked)
field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "status", "")
require.NoError(t, err)
require.NotNil(t, field)
assert.Equal(t, "12345", field.ID)
optionID, err := resolveSingleSelectOptionByName(field, "in progress")
require.NoError(t, err)
assert.Equal(t, "OPT_b", optionID)
}
// Test_ResolveFieldNamesToIDs_CaseInsensitive verifies bulk name resolution
// also matches case-insensitively.
func Test_ResolveFieldNamesToIDs_CaseInsensitive(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil),
})),
),
)
gql := githubv4.NewClient(mocked)
ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"status", "PRIORITY"}, "fields")
require.NoError(t, err)
assert.Equal(t, []int64{100, 200}, ids)
}
func Test_ResolveFieldNamesToIDs_IDParameterErrors(t *testing.T) {
tests := []struct {
name string
fields []ResolvedField
idParameter string
want string
}{
{
name: "normal project item fields",
fields: []ResolvedField{
{ID: "100", Name: "Status"},
{ID: "200", Name: "Status"},
},
idParameter: "fields",
want: "'fields'",
},
{
name: "project view visible fields",
fields: []ResolvedField{
{ID: "100", Name: "Status"},
{ID: "200", Name: "Status"},
},
idParameter: "visible_fields",
want: "'visible_fields'",
},
{
name: "nonnumeric project item field ID",
fields: []ResolvedField{{ID: "not-numeric", Name: "Status"}},
idParameter: "fields",
want: "'fields'",
},
{
name: "nonnumeric project view field ID",
fields: []ResolvedField{{ID: "not-numeric", Name: "Status"}},
idParameter: "visible_fields",
want: "'visible_fields'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := resolveFieldNamesToIDsFromFields(tt.fields, []string{"Status"}, "octo-org", 1, tt.idParameter)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
// Test_ProjectsWrite_UpdateProjectItem_ByName is the acceptance test for the
// write side: set Status = "In Progress" using only names plus an issue number.
func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
updatedItem := verbosePullRequestProjectItemFixture()
mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem),
})
restClient := mustNewGHClient(t, mockedREST)
mockedGQL := githubv4mock.NewMockedHTTPClient(
// 1. project node id (used by resolveProjectItemIDByIssueNumber)
githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{"id": "PVT_project1"},
},
}),
),
// 2. issue -> projectItems lookup
githubv4mock.NewQueryMatcher(
resolveItemByIssueQuery{},
map[string]any{
"issueOwner": githubv4.String("github"),
"issueRepo": githubv4.String("planning-tracking"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"fullDatabaseId": "1001",
"project": map[string]any{"id": "PVT_project1"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false, "hasPreviousPage": false,
"startCursor": "", "endCursor": "",
},
},
},
},
}),
),
// 3. fields(first:100) for name resolution
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{
{"id": "OPT_in_progress", "name": "In Progress"},
}),
})),
),
// 4. supplemental update metadata confirms this is a standard Project field
githubv4mock.NewQueryMatcher(
projectIssueFieldMetadataQueryOrg{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"fields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "ProjectV2SingleSelectField",
"databaseId": 101,
"isIssueField": false,
},
},
"pageInfo": map[string]any{
"hasNextPage": false, "hasPreviousPage": false,
"startCursor": "", "endCursor": "",
},
},
},
},
}),
),
)
gqlClient := githubv4.NewClient(mockedGQL)
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_item",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"item_owner": "github",
"item_repo": "planning-tracking",
"issue_number": float64(123),
"updated_field": map[string]any{
"name": "Status",
"value": "In Progress",
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
}
func Test_ProjectsWrite_UpdateProjectItem_ByNameIteration(t *testing.T) {
updatedItem := verbosePullRequestProjectItemFixture()
restCalled := false
mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, r *http.Request) {
restCalled = true
var update struct {
Fields []struct {
ID int64 `json:"id"`
Value any `json:"value"`
} `json:"fields"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&update))
require.Len(t, update.Fields, 1)
assert.Equal(t, int64(222), update.Fields[0].ID)
assert.Equal(t, "ITERATION_1", update.Fields[0].Value)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(updatedItem))
},
})
mockedGQL := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
iterationFieldNode("PVTIF_iteration1", 222, "Sprint"),
})),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, mockedREST),
GQLClient: githubv4.NewClient(mockedGQL),
}
toolDef := ProjectsWrite(translations.NullTranslationHelper)
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_item",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"item_id": float64(1001),
"updated_field": map[string]any{
"name": "Sprint",
"value": "ITERATION_1",
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
assert.True(t, restCalled)
}
func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
mockedGQL := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", nil),
})),
),
)
gqlClient := githubv4.NewClient(mockedGQL)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_item",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"item_id": float64(1001),
"updated_field": map[string]any{
"name": "Doesnt Exist",
"value": "whatever",
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
textContent := getTextResult(t, result)
var msg map[string]any
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &msg))
assert.Equal(t, "field_not_found", msg["error"])
assert.Equal(t, "Doesnt Exist", msg["name"])
}