d080b23f59
* 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
1544 lines
53 KiB
Go
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)
|
|
}
|