Files
Lizeth Vera d080b23f59 Add batched update_project_items writes via GraphQL (#2903)
* Implement batch project write engine

Resolve and validate shared field updates and item references before executing ordered, chunked GraphQL writes with explicit ambiguous outcomes.

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

Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0

* Expose update_project_items

Add the public projects_write contract, routing, handler coverage, and generated documentation for shared field updates across batches of up to 50 items.

Co-authored-by: Lizeth Vera <47796851+veralizeth@users.noreply.github.com>

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

Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0

* Classify batch resolution failures

Use a neutral code for non-structured lookup failures while preserving structured resolution details.

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

Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0

* Resolve issue references concurrently

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0
Copilot-Session: 5709a470-df75-43ec-9a9c-98868e6065d2

---------

Co-authored-by: Bryan Zwicker <zwick@github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0
Copilot-Session: 5709a470-df75-43ec-9a9c-98868e6065d2
2026-07-29 15:57:26 -04:00

1544 lines
53 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"io"
"maps"
"math"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fieldNode is a generic project field response node for use in mock data,
// covering data types beyond SINGLE_SELECT (statusFieldNode in
// projects_resolver_test.go is fixed to SINGLE_SELECT). See the comment on
// listAllProjectFields's inline-fragment decoding: the underlying jsonutil
// decoder populates id/databaseId/name/dataType identically across all three
// ProjectV2*Field fragments for a flat node object, so a single flat map
// (with "options" only where relevant) is sufficient regardless of dataType.
func fieldNode(nodeID string, databaseID int, name, dataType string) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"name": name,
"dataType": dataType,
}
}
// projectIDMatcher returns the githubv4mock matcher for the org project-node-ID
// resolution query issued once per update_project_items call.
func projectIDMatcher(owner string, projectNumber int, projectNodeID string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}{},
map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{"id": projectNodeID},
},
}),
)
}
// mutationAwareTransport routes GraphQL requests to a fixed query-matcher
// transport (e.g. githubv4mock.NewMockedHTTPClient's Transport) for ordinary
// queries/lookups, and to a sequenced, call-counted responder for mutation
// requests, so end-to-end tests can assert on aliased-mutation call counts and
// per-call variables without needing to hand-construct the exact minified
// mutation query text that reflect.StructOf produces.
type mutationAwareTransport struct {
t *testing.T
queries http.RoundTripper
mutationRespond func(callIndex int, req capturedGraphQLRequest) (status int, body string)
queryCalls []capturedGraphQLRequest
mutationCalls []capturedGraphQLRequest
}
func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
_ = req.Body.Close()
var parsed struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, err
}
if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") {
m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables})
req.Body = io.NopCloser(strings.NewReader(string(raw)))
return m.queries.RoundTrip(req)
}
captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}
idx := len(m.mutationCalls)
m.mutationCalls = append(m.mutationCalls, captured)
if m.mutationRespond == nil {
m.t.Fatalf("unexpected mutation call #%d (query: %s)", idx, parsed.Query)
}
status, body := m.mutationRespond(idx, captured)
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}
type gatedIssueLookupTransport struct {
gate <-chan struct{}
started chan int
projectID string
mu sync.Mutex
active int
peak int
calls map[int]int
}
func newGatedIssueLookupTransport(gate <-chan struct{}, projectID string) *gatedIssueLookupTransport {
return &gatedIssueLookupTransport{
gate: gate,
started: make(chan int, maxProjectItemsPerBatch),
projectID: projectID,
calls: make(map[int]int),
}
}
func (t *gatedIssueLookupTransport) RoundTrip(req *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
_ = req.Body.Close()
var parsed struct {
Variables map[string]any `json:"variables"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, err
}
rawIssueNumber, ok := parsed.Variables["issueNumber"].(float64)
if !ok {
return nil, fmt.Errorf("issueNumber variable is missing or invalid")
}
issueNumber := int(rawIssueNumber)
t.mu.Lock()
t.calls[issueNumber]++
t.active++
t.peak = max(t.peak, t.active)
t.mu.Unlock()
defer func() {
t.mu.Lock()
t.active--
t.mu.Unlock()
}()
t.started <- issueNumber
select {
case <-t.gate:
case <-req.Context().Done():
return nil, req.Context().Err()
}
body, err := json.Marshal(map[string]any{
"data": map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"projectItems": map[string]any{
"nodes": []any{
map[string]any{
"id": fmt.Sprintf("PVTI_item%d", issueNumber),
"fullDatabaseId": fmt.Sprintf("%d", 1000+issueNumber),
"project": map[string]any{"id": t.projectID},
},
},
"pageInfo": map[string]any{
"hasNextPage": false, "hasPreviousPage": false,
"startCursor": "page-one", "endCursor": "page-one",
},
},
},
},
},
})
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(body))),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
func (t *gatedIssueLookupTransport) snapshot() (active int, peak int, calls map[int]int) {
t.mu.Lock()
defer t.mu.Unlock()
return t.active, t.peak, maps.Clone(t.calls)
}
func issueBatchItems(issueNumbers ...int) []parsedBatchItem {
items := make([]parsedBatchItem, 0, len(issueNumbers))
for index, issueNumber := range issueNumbers {
items = append(items, parsedBatchItem{
index: index,
refKind: batchRefIssue,
issueOwner: "octo-org",
issueRepo: "roadmap",
issueNumber: issueNumber,
})
}
return items
}
func waitForIssueLookups(ctx context.Context, t *testing.T, started <-chan int, count int) {
t.Helper()
for range count {
select {
case <-started:
case <-ctx.Done():
t.Fatalf("timed out waiting for %d issue lookups to start: %v", count, ctx.Err())
}
}
}
func waitForIssueLookupResults(ctx context.Context, t *testing.T, results <-chan map[issueRefKey]itemLookupResult) map[issueRefKey]itemLookupResult {
t.Helper()
select {
case resolved := <-results:
return resolved
case <-ctx.Done():
t.Fatalf("timed out waiting for issue lookups to finish: %v", ctx.Err())
return nil
}
}
func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) {
tooMany := make([]any, maxProjectItemsPerBatch+1)
validItem := map[string]any{"node_id": "PVTI_item1"}
validField := map[string]any{"name": "Notes", "value": "hello"}
tests := []struct {
name string
args map[string]any
wantErr string
}{
{name: "missing items", args: map[string]any{}, wantErr: "missing required parameter: items"},
{name: "non-array items", args: map[string]any{"items": "invalid"}, wantErr: "items must be an array"},
{name: "empty items", args: map[string]any{"items": []any{}}, wantErr: "items must contain at least one entry"},
{name: "too many items", args: map[string]any{"items": tooMany}, wantErr: "items exceeds maximum of 50 entries"},
{name: "missing updated field", args: map[string]any{"items": []any{validItem}}, wantErr: "missing required parameter: updated_field"},
{name: "malformed updated field", args: map[string]any{"items": []any{validItem}, "updated_field": "invalid"}, wantErr: "updated_field must be an object"},
{name: "missing field value", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"name": "Notes"}}, wantErr: "updated_field.value is required"},
{name: "missing field reference", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"value": "hello"}}, wantErr: "updated_field requires either id or name"},
{name: "ambiguous field reference", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"id": float64(1), "name": "Notes", "value": "hello"}}, wantErr: "updated_field must set either id or name"},
{name: "nil GraphQL client", args: map[string]any{"items": []any{validItem}, "updated_field": validField}, wantErr: "gqlClient is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, structured, err := updateProjectItemsBatch(t.Context(), nil, nil, "octo-org", "org", 1, tt.args)
require.NoError(t, err)
assert.Nil(t, structured)
assert.Contains(t, getErrorResult(t, result).Text, tt.wantErr)
})
}
}
func Test_UpdateProjectItemsBatch_InvalidSharedValueIsTopLevelError(t *testing.T) {
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{
{"id": "OPT_todo", "name": "Todo"},
}),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) {
t.Fatal("invalid shared values must fail before writes")
return http.StatusInternalServerError, ""
},
}
result, structured, err := updateProjectItemsBatch(
t.Context(),
nil,
newTestGQLClient(transport),
"octo-org",
"org",
1,
map[string]any{
"updated_field": map[string]any{"name": "Status", "value": "Missing"},
"items": []any{map[string]any{"node_id": "PVTI_item1"}},
},
)
require.NoError(t, err)
assert.Nil(t, structured)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getErrorResult(t, result).Text), &response))
assert.Equal(t, "option_not_found", response["error"])
assert.Equal(t, "Missing", response["name"])
assert.Equal(t, []any{map[string]any{"name": "Todo"}}, response["candidates"])
assert.Empty(t, transport.mutationCalls)
}
func Test_ProjectsWrite_UpdateProjectItems_NodeIDBypassesRESTLookup(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
assert.Contains(t, req.Query, "updateProjectV2ItemFieldValue")
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"},
})
},
}
gqlClient := newTestGQLClient(transport)
// No REST handlers registered at all: if the implementation ever fell back
// to a REST lookup for a node_id-addressed item, this would 404.
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_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "hello"},
"items": []any{
map[string]any{"node_id": "PVTI_item1"},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
assert.Equal(t, float64(0), response["failed"])
assert.Equal(t, float64(0), response["unknown"])
}
func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
require.Len(t, req.Variables, 1)
assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"])
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item1001", FullDatabaseID: "1001"},
})
},
}
gqlClient := newTestGQLClient(transport)
var restCalls int32
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&restCalls, 1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`))
},
}))
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "hello"},
"items": []any{
map[string]any{"item_id": float64(1001)},
map[string]any{"item_id": float64(1001)},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
assert.Equal(t, float64(1), response["failed"])
assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls), "the same numeric item_id must only be resolved once")
results := response["results"].([]any)
assert.Equal(t, "duplicate_target", results[1].(map[string]any)["error"].(map[string]any)["code"])
}
func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
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{
"id": "PVTI_other",
"fullDatabaseId": "9999",
"project": map[string]any{"id": "PVT_other"},
},
},
"pageInfo": map[string]any{
"hasNextPage": true, "hasPreviousPage": false,
"startCursor": "page-one", "endCursor": "page-one",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
resolveItemByIssuePageQuery{},
map[string]any{
"issueOwner": githubv4.String("github"),
"issueRepo": githubv4.String("planning-tracking"),
"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_item2002",
"fullDatabaseId": "2002",
"project": map[string]any{"id": "PVT_project1"},
},
},
"pageInfo": map[string]any{
"hasNextPage": false, "hasPreviousPage": true,
"startCursor": "page-two", "endCursor": "page-two",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
require.Len(t, req.Variables, 1)
assert.Equal(t, "PVTI_item2002", req.Variables["input"].(map[string]any)["itemId"])
assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"])
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"},
})
},
}
gqlClient := newTestGQLClient(transport)
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_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "hello"},
"items": []any{
map[string]any{
"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123),
},
map[string]any{
"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123),
},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
assert.Equal(t, float64(1), response["failed"])
results := response["results"].([]any)
item := results[0].(map[string]any)["item"].(map[string]any)
assert.Equal(t, "PVTI_item2002", item["node_id"])
assert.Equal(t, "2002", item["full_database_id"])
assert.Equal(t, "duplicate_target", results[1].(map[string]any)["error"].(map[string]any)["code"])
issueResolutionCalls := 0
for _, call := range transport.queryCalls {
if strings.Contains(call.Query, "projectItems") {
issueResolutionCalls++
}
}
assert.Equal(t, 2, issueResolutionCalls, "duplicate issue refs should share one two-page resolution chain")
assert.Len(t, transport.queryCalls, 4, "expected project, fields, and two issue-page queries")
}
func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
require.Len(t, req.Variables, 1)
assert.Equal(t, 1, strings.Count(req.Query, "updateProjectV2ItemFieldValue"))
assert.Equal(t, "PVTI_item1", req.Variables["input"].(map[string]any)["itemId"])
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"},
})
},
}
gqlClient := newTestGQLClient(transport)
var restCalls int32
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&restCalls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1"}`))
},
}))
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "hello"},
"items": []any{
map[string]any{"node_id": "PVTI_item1"},
map[string]any{"item_id": float64(1001)},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
assert.Equal(t, float64(1), response["failed"])
results := response["results"].([]any)
second := results[1].(map[string]any)
assert.Equal(t, "failed", second["status"])
assert.Equal(t, "duplicate_target", second["error"].(map[string]any)["code"])
assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls))
assert.Len(t, transport.mutationCalls, 1)
}
func Test_ProjectsWrite_UpdateProjectItems_TwentyWritesIsOneMutationRequest(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
transport := chunkSizeTestRun(t, toolDef, 20)
assert.Len(t, transport.mutationCalls, 1)
}
func Test_ProjectsWrite_UpdateProjectItems_TwentyOneWritesIsTwoMutationRequests(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
transport := chunkSizeTestRun(t, toolDef, 21)
assert.Len(t, transport.mutationCalls, 2)
}
func Test_ProjectsWrite_UpdateProjectItems_MaximumWritesIsThreeMutationRequests(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
transport := chunkSizeTestRun(t, toolDef, maxProjectItemsPerBatch)
assert.Len(t, transport.mutationCalls, 3)
}
// chunkSizeTestRun runs an update_project_items call with itemCount node_id
// items (all TEXT field updates), returning the mutationAwareTransport so the
// caller can assert on how many aliased-mutation HTTP requests were made.
func chunkSizeTestRun(t *testing.T, toolDef inventory.ServerTool, itemCount int) *mutationAwareTransport {
t.Helper()
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
// input (index 0) plus inputN for each additional alias in this chunk.
chunkSize := len(req.Variables)
ids := make(map[int]struct{ NodeID, FullDatabaseID string }, chunkSize)
for i := range chunkSize {
ids[i] = struct{ NodeID, FullDatabaseID string }{
NodeID: "PVTI_chunk",
FullDatabaseID: "1",
}
}
return http.StatusOK, mutationDataResponse(t, ids)
},
}
gqlClient := newTestGQLClient(transport)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
items := make([]any, itemCount)
for i := range itemCount {
items[i] = map[string]any{"node_id": fmt.Sprintf("PVTI_item%d", i)}
}
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "hello"},
"items": items,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(itemCount), response["succeeded"])
return transport
}
func Test_ProjectsWrite_UpdateProjectItems_SharedNullClearsAllItemsInOrder(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) {
assert.Contains(t, req.Query, "clearProjectV2ItemFieldValue")
assert.NotContains(t, req.Query, "updateProjectV2ItemFieldValue")
for _, input := range req.Variables {
assert.NotContains(t, input.(map[string]any), "value")
}
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"},
1: {NodeID: "PVTI_item1", FullDatabaseID: "1001"},
2: {NodeID: "PVTI_item2", FullDatabaseID: "1002"},
})
},
}
gqlClient := newTestGQLClient(transport)
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_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": nil},
"items": []any{
map[string]any{"node_id": "PVTI_item0"},
map[string]any{"node_id": "PVTI_item1"},
map[string]any{"node_id": "PVTI_item2"},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(3), response["succeeded"])
results := response["results"].([]any)
require.Len(t, results, 3)
for i, r := range results {
entry := r.(map[string]any)
assert.Equal(t, float64(i), entry["index"])
assert.Equal(t, "succeeded", entry["status"])
assert.Equal(t, fmt.Sprintf("%d", 1000+i), entry["item"].(map[string]any)["full_database_id"])
}
assert.Len(t, transport.mutationCalls, 1)
}
func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(callIndex int, _ capturedGraphQLRequest) (int, string) {
if callIndex == 0 {
// Systemic transport-level failure: no data at all.
return http.StatusInternalServerError, `{"message":"internal server error"}`
}
t.Fatalf("chunk #%d must not execute after an ambiguous chunk-level failure", callIndex)
return http.StatusInternalServerError, ""
},
}
gqlClient := newTestGQLClient(transport)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
items := make([]any, 25)
for i := range 25 {
items[i] = map[string]any{"node_id": fmt.Sprintf("PVTI_item%d", i)}
}
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "x"},
"items": items,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
// No item succeeded (all unknown after the abort), so IsError is set per
// the "no item succeeded" rule, even though nothing was deterministically
// rejected; the structured result (with unknown statuses) is still available.
assert.True(t, result.IsError)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(0), response["succeeded"])
assert.Equal(t, float64(25), response["unknown"])
assert.Len(t, transport.mutationCalls, 1, "only the first (failing) chunk should have been sent")
results := response["results"].([]any)
for _, r := range results {
assert.Equal(t, "unknown", r.(map[string]any)["status"])
}
}
func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
mocked := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
)
countingTransport := &requestCountingTransport{inner: mocked.Transport}
gqlClient := newTestGQLClient(countingTransport)
deps := BaseDeps{Client: restClient, GQLClient: gqlClient}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "x"},
"items": []any{
map[string]any{},
map[string]any{"node_id": ""},
map[string]any{"item_id": float64(0)},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.True(t, result.IsError, "IsError must be set when no item in the batch succeeds")
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(0), response["succeeded"])
assert.Equal(t, float64(3), response["failed"])
assert.Zero(t, countingTransport.count, "an all-invalid batch should not perform GraphQL resolution")
}
func Test_ProjectsWrite_UpdateProjectItems_MixedOutcomeKeepsIsErrorFalse(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"},
})
},
}
gqlClient := newTestGQLClient(transport)
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_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "x"},
"items": []any{
map[string]any{"node_id": "PVTI_item0"},
map[string]any{}, // deterministic failure
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError, "mixed outcomes must keep IsError false so the structured result stays available")
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
assert.Equal(t, float64(1), response["failed"])
}
// Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring verifies the
// batch mutation path works unchanged when gqlClient was constructed via
// githubv4.NewEnterpriseClient (GHES), not just githubv4.NewClient: the
// reflection-based mutation logic never assumes a specific endpoint and only
// ever uses the injected client.
func Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)
queryTransport := githubv4mock.NewMockedHTTPClient(
projectIDMatcher("octo-org", 1, "PVT_project1"),
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
fieldNode("PVTF_notes", 101, "Notes", "TEXT"),
})),
),
)
transport := &mutationAwareTransport{
t: t,
queries: queryTransport.Transport,
mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"},
})
},
}
gqlClient := githubv4.NewEnterpriseClient("https://ghe.example.com/graphql", &http.Client{Transport: transport})
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_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"updated_field": map[string]any{"name": "Notes", "value": "x"},
"items": []any{
map[string]any{"node_id": "PVTI_item0"},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, float64(1), response["succeeded"])
}
func Test_ParseItemRef_ExactlyOneFormRequired(t *testing.T) {
tests := []struct {
name string
entry map[string]any
wantErr string
}{
{
name: "none provided",
entry: map[string]any{},
wantErr: "exactly one of",
},
{
name: "node_id and item_id both provided",
entry: map[string]any{"node_id": "PVTI_x", "item_id": float64(1)},
wantErr: "not more than one",
},
{
name: "item_id and issue ref both provided",
entry: map[string]any{"item_id": float64(1), "item_owner": "o", "item_repo": "r", "issue_number": float64(1)},
wantErr: "not more than one",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(tt.entry)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func Test_ParseItemRef_NodeIDBypassesLookup(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(map[string]any{"node_id": "PVTI_abc123"})
require.NoError(t, err)
assert.Equal(t, batchRefNodeID, p.refKind)
assert.Equal(t, "PVTI_abc123", p.nodeID)
}
func Test_ParseItemRef_ItemID(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(map[string]any{"item_id": float64(42)})
require.NoError(t, err)
assert.Equal(t, batchRefItemID, p.refKind)
assert.Equal(t, int64(42), p.itemID)
}
func Test_ParseItemRef_IssueRef(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)})
require.NoError(t, err)
assert.Equal(t, batchRefIssue, p.refKind)
assert.Equal(t, "github", p.issueOwner)
assert.Equal(t, "planning-tracking", p.issueRepo)
assert.Equal(t, 123, p.issueNumber)
}
func Test_ParseItemRef_InvalidNumericReferences(t *testing.T) {
issueRef := func(value any) map[string]any {
return map[string]any{
"item_owner": "github",
"item_repo": "planning-tracking",
"issue_number": value,
}
}
tests := []struct {
name string
entry map[string]any
}{
{name: "zero item ID", entry: map[string]any{"item_id": float64(0)}},
{name: "negative item ID", entry: map[string]any{"item_id": float64(-1)}},
{name: "fractional item ID", entry: map[string]any{"item_id": float64(1.5)}},
{name: "NaN item ID", entry: map[string]any{"item_id": math.NaN()}},
{name: "infinite item ID", entry: map[string]any{"item_id": math.Inf(1)}},
{name: "overflowing item ID", entry: map[string]any{"item_id": math.MaxFloat64}},
{name: "zero issue number", entry: issueRef(float64(0))},
{name: "negative issue number", entry: issueRef(float64(-1))},
{name: "fractional issue number", entry: issueRef(float64(1.5))},
{name: "overflowing issue number", entry: issueRef(float64(math.MaxInt32) + 1)},
{name: "NaN issue number", entry: issueRef(math.NaN())},
{name: "infinite issue number", entry: issueRef(math.Inf(1))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(tt.entry)
require.Error(t, err)
})
}
}
func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) {
p := parsedBatchItem{}
err := p.parseItemRef(map[string]any{"item_owner": "github"})
require.Error(t, err)
assert.Contains(t, err.Error(), "must all be provided together")
}
func Test_ParseBatchItemEntry_InvalidShape(t *testing.T) {
p := parseBatchItemEntry(0, "not-an-object")
require.NotNil(t, p.err)
assert.Equal(t, "invalid_item", p.err.Code)
}
func Test_ParseBatchItemEntry_RejectsPerItemUpdatedField(t *testing.T) {
p := parseBatchItemEntry(0, map[string]any{
"node_id": "PVTI_1",
"updated_field": map[string]any{"name": "Notes", "value": "x"},
})
require.NotNil(t, p.err)
assert.Contains(t, p.err.Message, "use the top-level updated_field")
}
func Test_ConvertProjectFieldValue_Text(t *testing.T) {
field := &ResolvedField{Name: "Notes", DataType: "TEXT"}
v, err := convertProjectFieldValue(field, "hello")
require.NoError(t, err)
require.NotNil(t, v.Text)
assert.Equal(t, "hello", string(*v.Text))
}
func Test_ConvertProjectFieldValue_Text_WrongType(t *testing.T) {
field := &ResolvedField{Name: "Notes", DataType: "TEXT"}
_, err := convertProjectFieldValue(field, float64(1))
require.Error(t, err)
}
func Test_ConvertProjectFieldValue_Number(t *testing.T) {
field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"}
v, err := convertProjectFieldValue(field, float64(8))
require.NoError(t, err)
require.NotNil(t, v.Number)
assert.InDelta(t, 8.0, float64(*v.Number), 0.0001)
}
func Test_ConvertProjectFieldValue_Number_NonFinite(t *testing.T) {
field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"}
for _, value := range []float64{math.NaN(), math.Inf(-1), math.Inf(1)} {
_, err := convertProjectFieldValue(field, value)
require.Error(t, err)
}
}
func Test_ConvertProjectFieldValue_Date(t *testing.T) {
field := &ResolvedField{Name: "Due", DataType: "DATE"}
v, err := convertProjectFieldValue(field, "2024-01-15")
require.NoError(t, err)
require.NotNil(t, v.Date)
assert.Equal(t, 2024, v.Date.Year())
assert.Equal(t, 1, int(v.Date.Month()))
assert.Equal(t, 15, v.Date.Day())
}
func Test_ConvertProjectFieldValue_Date_BadFormat(t *testing.T) {
field := &ResolvedField{Name: "Due", DataType: "DATE"}
_, err := convertProjectFieldValue(field, "01/15/2024")
require.Error(t, err)
}
func Test_ConvertProjectFieldValue_SingleSelect_ByName(t *testing.T) {
field := &ResolvedField{
Name: "Status",
DataType: "SINGLE_SELECT",
Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}},
}
v, err := convertProjectFieldValue(field, "In Progress")
require.NoError(t, err)
require.NotNil(t, v.SingleSelectOptionID)
assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID))
}
func Test_ConvertProjectFieldValue_SingleSelect_ByOptionID(t *testing.T) {
field := &ResolvedField{
Name: "Status",
DataType: "SINGLE_SELECT",
Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}},
}
v, err := convertProjectFieldValue(field, "OPT_1")
require.NoError(t, err)
require.NotNil(t, v.SingleSelectOptionID)
assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID))
}
func Test_ConvertProjectFieldValue_SingleSelect_Unknown(t *testing.T) {
field := &ResolvedField{
Name: "Status",
DataType: "SINGLE_SELECT",
Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}},
}
_, err := convertProjectFieldValue(field, "Nonexistent")
require.Error(t, err)
}
func Test_ConvertProjectFieldValue_Iteration(t *testing.T) {
field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"}
v, err := convertProjectFieldValue(field, "abc123==")
require.NoError(t, err)
require.NotNil(t, v.IterationID)
assert.Equal(t, "abc123==", string(*v.IterationID))
}
func Test_ConvertProjectFieldValue_Iteration_EmptyIsError(t *testing.T) {
field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"}
_, err := convertProjectFieldValue(field, "")
require.Error(t, err)
}
func Test_ConvertProjectFieldValue_UnsupportedDataType(t *testing.T) {
field := &ResolvedField{Name: "Assignees", DataType: "ASSIGNEES"}
_, err := convertProjectFieldValue(field, "someone")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported data type")
assert.Contains(t, err.Error(), "update_project_item")
}
func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) {
tests := []struct {
name string
spec batchFieldSpec
wantID string
}{
{name: "numeric ID", spec: batchFieldSpec{id: 101}, wantID: "PVTF_status"},
{name: "case-insensitive name", spec: batchFieldSpec{name: "priority"}, wantID: "PVTF_priority"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTF_status", 101, "Status", nil),
statusFieldNode("PVTF_priority", 202, "Priority", nil),
})),
),
)
field, err := resolveBatchProjectField(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, tt.spec)
require.NoError(t, err)
assert.Equal(t, tt.wantID, field.NodeID)
})
}
}
func Test_ResolveBatchProjectField_AmbiguousName(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_status1", 101, "Status", nil),
statusFieldNode("PVTSSF_status2", 202, "Status", nil),
})),
),
)
_, err := resolveBatchProjectField(
t.Context(),
githubv4.NewClient(mocked),
"octo-org",
"org",
7,
batchFieldSpec{name: "status"},
)
require.Error(t, err)
var response struct {
Error string `json:"error"`
Candidates []map[string]any `json:"candidates"`
}
require.NoError(t, json.Unmarshal([]byte(err.Error()), &response))
assert.Equal(t, "field_ambiguous", response.Error)
require.Len(t, response.Candidates, 2)
assert.ElementsMatch(t, []any{"101", "202"}, []any{response.Candidates[0]["id"], response.Candidates[1]["id"]})
}
func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing.T) {
tests := []struct {
name string
ownerType string
endpoint string
}{
{name: "organization", ownerType: "org", endpoint: GetOrgsProjectsV2ItemsByProjectByItemID},
{name: "user", ownerType: "user", endpoint: GetUsersProjectsV2ItemsByUsernameByProjectByItemID},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := 0
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
tt.endpoint: func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`))
},
}))
resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001})
require.NoError(t, resolved[1001].err)
assert.Equal(t, "PVTI_item1001", resolved[1001].nodeID)
assert.Equal(t, 1, calls)
})
}
}
func Test_ResolveIssueRefs_DeduplicatesAndBoundsConcurrency(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
gate := make(chan struct{})
transport := newGatedIssueLookupTransport(gate, "PVT_project")
results := make(chan map[issueRefKey]itemLookupResult, 1)
go func() {
results <- resolveIssueRefs(
ctx,
newTestGQLClient(transport),
githubv4.ID("PVT_project"),
issueBatchItems(1, 2, 3, 4, 5, 6, 1),
)
}()
waitForIssueLookups(ctx, t, transport.started, batchItemLookupConcurrency)
active, peak, calls := transport.snapshot()
assert.Equal(t, batchItemLookupConcurrency, active)
assert.Equal(t, batchItemLookupConcurrency, peak)
assert.Len(t, calls, batchItemLookupConcurrency)
close(gate)
resolved := waitForIssueLookupResults(ctx, t, results)
require.Len(t, resolved, 6)
for issueNumber := 1; issueNumber <= 6; issueNumber++ {
key := issueRefKey{owner: "octo-org", repo: "roadmap", number: issueNumber}
result, ok := resolved[key]
require.True(t, ok)
require.NoError(t, result.err)
assert.Equal(t, fmt.Sprintf("PVTI_item%d", issueNumber), result.nodeID)
assert.Equal(t, int64(1000+issueNumber), result.fullDatabaseID)
}
active, peak, calls = transport.snapshot()
assert.Zero(t, active)
assert.Equal(t, batchItemLookupConcurrency, peak)
require.Len(t, calls, 6)
for issueNumber := 1; issueNumber <= 6; issueNumber++ {
assert.Equal(t, 1, calls[issueNumber])
}
}
func Test_ResolveIssueRefs_CancellationPopulatesWaitingRefs(t *testing.T) {
testCtx, stop := context.WithTimeout(t.Context(), 5*time.Second)
defer stop()
ctx, cancel := context.WithCancel(testCtx)
defer cancel()
gate := make(chan struct{})
defer close(gate)
transport := newGatedIssueLookupTransport(gate, "PVT_project")
results := make(chan map[issueRefKey]itemLookupResult, 1)
go func() {
results <- resolveIssueRefs(
ctx,
newTestGQLClient(transport),
githubv4.ID("PVT_project"),
issueBatchItems(1, 2, 3, 4, 5, 6, 7),
)
}()
waitForIssueLookups(testCtx, t, transport.started, batchItemLookupConcurrency)
_, peak, startedCalls := transport.snapshot()
require.Equal(t, batchItemLookupConcurrency, peak)
require.Len(t, startedCalls, batchItemLookupConcurrency)
cancel()
resolved := waitForIssueLookupResults(testCtx, t, results)
require.Len(t, resolved, 7)
waiting := 0
for issueNumber := 1; issueNumber <= 7; issueNumber++ {
key := issueRefKey{owner: "octo-org", repo: "roadmap", number: issueNumber}
result, ok := resolved[key]
require.True(t, ok)
require.ErrorIs(t, result.err, context.Canceled)
if _, started := startedCalls[issueNumber]; !started {
waiting++
assert.Equal(t, context.Canceled, result.err)
}
}
assert.Equal(t, 2, waiting)
active, peak, calls := transport.snapshot()
assert.Zero(t, active)
assert.Equal(t, batchItemLookupConcurrency, peak)
assert.Equal(t, startedCalls, calls)
}
func Test_BatchErrorFromResolution(t *testing.T) {
t.Run("generic wrapped error", func(t *testing.T) {
err := batchErrorFromResolution(fmt.Errorf("item lookup failed: %w", context.DeadlineExceeded))
assert.Equal(t, "resolution_failed", err.Code)
assert.Equal(t, "item lookup failed: context deadline exceeded", err.Message)
})
t.Run("structured error", func(t *testing.T) {
candidates := []any{map[string]any{"id": "PVTI_1"}}
structured := ghErrors.NewStructuredResolutionError(
"item_not_found",
"octo/repo#42",
"Check that the item belongs to the project.",
candidates,
)
err := batchErrorFromResolution(fmt.Errorf("resolve item: %w", structured))
assert.Equal(t, structured.Kind, err.Code)
assert.Equal(t, "item_not_found: octo/repo#42", err.Message)
assert.Equal(t, structured.Hint, err.Hint)
assert.Equal(t, candidates, err.Candidates)
})
}
func Test_ExecuteBatchWrites_AllAliasGraphQLErrorContinues(t *testing.T) {
transport := &sequencedGraphQLTransport{
t: t,
responses: []func(capturedGraphQLRequest) (int, string){
func(_ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationErrorResponse(t, map[string]any{}, "all aliases failed")
},
func(_ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"},
})
},
},
}
items, results := batchItemsOfSize(21)
executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results)
assert.Len(t, transport.calls, 2)
for i := range 20 {
assert.Equal(t, batchItemUnknown, results[i].Status)
}
assert.Equal(t, batchItemSucceeded, results[20].Status)
}
func Test_ExecuteBatchWrites_PartialGraphQLErrorPreservesSuccess(t *testing.T) {
transport := &sequencedGraphQLTransport{
t: t,
responses: []func(capturedGraphQLRequest) (int, string){
func(_ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationErrorResponse(t, map[string]any{
"item0": map[string]any{
"projectV2Item": map[string]any{"id": "PVTI_item0", "fullDatabaseId": "1000"},
},
"item1": nil,
}, "item1 failed")
},
},
}
items, results := batchItemsOfSize(2)
executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results)
assert.Equal(t, batchItemSucceeded, results[0].Status)
assert.Equal(t, items[0].ref, results[0].Ref)
assert.Equal(t, batchItemUnknown, results[1].Status)
assert.Equal(t, items[1].ref, results[1].Ref)
}
func Test_ExecuteBatchWrites_AmbiguousSuccessResponseAborts(t *testing.T) {
tests := []struct {
name string
body string
confirmedSuccesses int
}{
{
name: "null data",
body: `{"data":null}`,
},
{
name: "missing data",
body: `{}`,
},
{
name: "partial data without errors",
body: mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"},
}),
confirmedSuccesses: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
transport := &sequencedGraphQLTransport{
t: t,
responses: []func(capturedGraphQLRequest) (int, string){
func(_ capturedGraphQLRequest) (int, string) {
return http.StatusOK, tt.body
},
func(_ capturedGraphQLRequest) (int, string) {
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"},
})
},
},
}
items, results := batchItemsOfSize(21)
executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results)
assert.Len(t, transport.calls, 1)
for i, result := range results {
if i < tt.confirmedSuccesses {
assert.Equal(t, batchItemSucceeded, result.Status)
continue
}
assert.Equal(t, batchItemUnknown, result.Status)
}
})
}
}
func Test_ExecuteBatchWrites_TransportTimeoutAborts(t *testing.T) {
transport := &errorGraphQLTransport{err: context.DeadlineExceeded}
items, results := batchItemsOfSize(21)
executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results)
assert.Equal(t, 1, transport.calls)
for _, result := range results {
assert.Equal(t, batchItemUnknown, result.Status)
}
}
func Test_ExecuteBatchWrites_CanceledContextSkipsWrites(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
transport := &sequencedGraphQLTransport{t: t}
items, results := batchItemsOfSize(21)
executeTestBatchWrites(ctx, newTestGQLClient(transport), items, results)
assert.Empty(t, transport.calls)
for _, result := range results {
assert.Equal(t, batchItemUnknown, result.Status)
}
}
func executeTestBatchWrites(ctx context.Context, gqlClient *githubv4.Client, items []resolvedBatchItem, results []batchItemResult) {
executeBatchWrites(
ctx,
batchWriteOperation{
gqlClient: gqlClient,
kind: batchMutationUpdate,
projectID: githubv4.ID("PVT_project"),
fieldID: githubv4.ID("PVTF_field"),
value: githubv4.ProjectV2FieldValue{Text: githubv4.NewString("value")},
},
items,
results,
)
}
func batchItemsOfSize(n int) ([]resolvedBatchItem, []batchItemResult) {
items := make([]resolvedBatchItem, n)
for i := range n {
nodeID := fmt.Sprintf("PVTI_item%d", i)
items[i] = resolvedBatchItem{
index: i,
ref: map[string]any{"node_id": nodeID},
nodeID: nodeID,
}
}
return items, make([]batchItemResult, n)
}