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

813 lines
22 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"time"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v89/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// Unknown outcomes cannot be attributed or retried safely because the pinned
// client drops errors[].path.
type batchItemStatus string
const (
batchItemSucceeded batchItemStatus = "succeeded"
batchItemFailed batchItemStatus = "failed"
batchItemUnknown batchItemStatus = "unknown"
)
type batchItemResult struct {
Index int `json:"index"`
Status batchItemStatus `json:"status"`
Item *batchItemIdentity `json:"item,omitempty"`
Error *batchItemError `json:"error,omitempty"`
// Ref preserves the request identity when resolution fails.
Ref map[string]any `json:"ref,omitempty"`
}
type batchItemIdentity struct {
NodeID string `json:"node_id,omitempty"`
FullDatabaseID string `json:"full_database_id,omitempty"`
ItemID int64 `json:"item_id,omitempty"`
}
type batchItemError struct {
Code string `json:"code"`
Message string `json:"message"`
Candidates []any `json:"candidates,omitempty"`
Hint string `json:"hint,omitempty"`
}
type resolvedBatchItem struct {
index int
ref map[string]any
nodeID string
fullDatabaseID int64
}
type batchWriteOperation struct {
gqlClient *githubv4.Client
kind batchMutationKind
projectID githubv4.ID
fieldID githubv4.ID
value githubv4.ProjectV2FieldValue
}
func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) {
rawItems, exists := args["items"]
if !exists {
return utils.NewToolResultError("missing required parameter: items"), nil, nil
}
itemsRaw, ok := rawItems.([]any)
if !ok {
return utils.NewToolResultError("items must be an array"), nil, nil
}
if len(itemsRaw) == 0 {
return utils.NewToolResultError("items must contain at least one entry"), nil, nil
}
if len(itemsRaw) > maxProjectItemsPerBatch {
return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", maxProjectItemsPerBatch, len(itemsRaw))), nil, nil
}
rawField, hasField := args["updated_field"]
if !hasField {
return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil
}
fieldSpec, fieldSpecErr := parseBatchFieldSpec(rawField)
if fieldSpecErr != nil {
return utils.NewToolResultError(fieldSpecErr.Error()), nil, nil
}
if gqlClient == nil {
return utils.NewToolResultError("internal error: gqlClient is required for update_project_items"), nil, nil
}
parsed := make([]parsedBatchItem, len(itemsRaw))
for i, raw := range itemsRaw {
parsed[i] = parseBatchItemEntry(i, raw)
}
results := make([]batchItemResult, len(itemsRaw))
pending := 0
for i, p := range parsed {
if p.err != nil {
results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: p.err}
} else {
pending++
}
}
if pending == 0 {
return newUpdateProjectItemsResult(results)
}
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
field, fieldErr := resolveBatchProjectField(ctx, gqlClient, owner, ownerType, projectNumber, fieldSpec)
if fieldErr != nil {
return batchTopLevelError(fieldErr), nil, nil
}
kind := batchMutationUpdate
var value githubv4.ProjectV2FieldValue
if fieldSpec.value == nil {
kind = batchMutationClear
} else {
value, fieldErr = convertProjectFieldValue(field, fieldSpec.value)
if fieldErr != nil {
return batchTopLevelError(fieldErr), nil, nil
}
}
var numericIDs []int64
for _, p := range parsed {
if p.err == nil && p.refKind == batchRefItemID {
numericIDs = append(numericIDs, p.itemID)
}
}
itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs)
issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed)
var work []resolvedBatchItem
seenTargets := make(map[string]int)
for i, p := range parsed {
if p.err != nil {
continue
}
nodeID, fullDatabaseID, lookupErr := resolveItemReference(p, itemIDLookups, issueLookups)
if lookupErr != nil {
results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(lookupErr)}
continue
}
if firstIndex, dup := seenTargets[nodeID]; dup {
results[i] = batchItemResult{
Index: i, Status: batchItemFailed, Ref: p.ref,
Error: &batchItemError{
Code: "duplicate_target",
Message: fmt.Sprintf("items[%d] targets the same project item as items[%d]; each item may only be written once per call", i, firstIndex),
},
}
continue
}
seenTargets[nodeID] = i
work = append(work, resolvedBatchItem{index: i, ref: p.ref, nodeID: nodeID, fullDatabaseID: fullDatabaseID})
}
executeBatchWrites(ctx, batchWriteOperation{
gqlClient: gqlClient,
kind: kind,
projectID: projectID,
fieldID: githubv4.ID(field.NodeID),
value: value,
}, work, results)
return newUpdateProjectItemsResult(results)
}
func batchTopLevelError(err error) *mcp.CallToolResult {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured)
}
return utils.NewToolResultError(err.Error())
}
func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult, any, error) {
succeeded, failed, unknown := 0, 0, 0
for _, r := range results {
switch r.Status {
case batchItemSucceeded:
succeeded++
case batchItemUnknown:
unknown++
default:
failed++
}
}
response := map[string]any{
"total": len(results),
"succeeded": succeeded,
"failed": failed,
"unknown": unknown,
"results": results,
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
result := utils.NewToolResultText(string(r))
if succeeded == 0 {
result.IsError = true
}
return result, nil, nil
}
func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult) (nodeID string, fullDatabaseID int64, err error) {
switch p.refKind {
case batchRefNodeID:
return p.nodeID, 0, nil
case batchRefItemID:
lookup := itemIDLookups[p.itemID]
if lookup.err != nil {
return "", 0, lookup.err
}
return lookup.nodeID, p.itemID, nil
case batchRefIssue:
key := issueRefKey{owner: p.issueOwner, repo: p.issueRepo, number: p.issueNumber}
lookup := issueLookups[key]
if lookup.err != nil {
return "", 0, lookup.err
}
return lookup.nodeID, lookup.fullDatabaseID, nil
default:
return "", 0, fmt.Errorf("internal error: unrecognised item reference kind")
}
}
// Transport, cancellation, or incomplete-data ambiguity stops later chunks;
// GraphQL response errors do not because populated aliases still confirm writes.
func executeBatchWrites(ctx context.Context, operation batchWriteOperation, items []resolvedBatchItem, results []batchItemResult) {
for start := 0; start < len(items); start += batchMutationWireChunkSize {
if ctx.Err() != nil {
markChunkUnknown(items[start:], results, ctx.Err())
return
}
end := min(start+batchMutationWireChunkSize, len(items))
chunk := items[start:end]
inputs := make([]githubv4.Input, len(chunk))
for i, item := range chunk {
if operation.kind == batchMutationClear {
inputs[i] = githubv4.ClearProjectV2ItemFieldValueInput{
ProjectID: operation.projectID,
ItemID: githubv4.ID(item.nodeID),
FieldID: operation.fieldID,
}
} else {
inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{
ProjectID: operation.projectID,
ItemID: githubv4.ID(item.nodeID),
FieldID: operation.fieldID,
Value: operation.value,
}
}
}
outcomes, mutateErr := executeAliasedMutation(ctx, operation.gqlClient, operation.kind, inputs)
populated := 0
for i, oc := range outcomes {
if oc.Populated {
populated++
results[chunk[i].index] = batchItemResult{
Index: chunk[i].index,
Status: batchItemSucceeded,
Ref: chunk[i].ref,
Item: &batchItemIdentity{
NodeID: oc.NodeID,
FullDatabaseID: oc.FullDatabaseID,
ItemID: chunk[i].fullDatabaseID,
},
}
}
}
if isGraphQLResponseError(mutateErr) {
markUnpopulatedUnknown(chunk, outcomes, results, mutateErr)
continue
}
if mutateErr != nil {
markChunkUnknown(items[start:], results, mutateErr)
return
}
if populated != len(chunk) {
markChunkUnknown(items[start:], results, fmt.Errorf("mutation response did not include every item"))
return
}
}
}
func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasOutcome, results []batchItemResult, err error) {
for i, oc := range outcomes {
if oc.Populated {
continue
}
results[chunk[i].index] = batchItemResult{
Index: chunk[i].index,
Status: batchItemUnknown,
Ref: chunk[i].ref,
Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()},
}
}
}
func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, err error) {
for _, item := range chunk {
if results[item.index].Status == batchItemSucceeded {
continue
}
results[item.index] = batchItemResult{
Index: item.index,
Status: batchItemUnknown,
Ref: item.ref,
Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()},
}
}
}
const batchItemLookupConcurrency = 5
type batchItemRefKind int
const (
batchRefNodeID batchItemRefKind = iota
batchRefItemID
batchRefIssue
)
type parsedBatchItem struct {
index int
ref map[string]any
refKind batchItemRefKind
nodeID string
itemID int64
issueOwner string
issueRepo string
issueNumber int
err *batchItemError
}
func parseBatchItemEntry(index int, raw any) parsedBatchItem {
p := parsedBatchItem{index: index}
entry, ok := raw.(map[string]any)
if !ok || entry == nil {
p.err = &batchItemError{Code: "invalid_item", Message: fmt.Sprintf("items[%d] must be an object", index)}
return p
}
p.ref = itemRefEcho(entry)
if _, hasUpdatedField := entry["updated_field"]; hasUpdatedField {
p.err = &batchItemError{Code: "invalid_item", Message: fmt.Sprintf("items[%d].updated_field is not supported; use the top-level updated_field", index)}
return p
}
if refErr := p.parseItemRef(entry); refErr != nil {
p.err = &batchItemError{Code: "invalid_item_ref", Message: refErr.Error()}
}
return p
}
func (p *parsedBatchItem) parseItemRef(entry map[string]any) error {
_, hasNodeID := entry["node_id"]
_, hasItemID := entry["item_id"]
_, hasOwner := entry["item_owner"]
_, hasRepo := entry["item_repo"]
_, hasIssueNumber := entry["issue_number"]
hasIssueRef := hasOwner || hasRepo || hasIssueNumber
formsPresent := 0
if hasNodeID {
formsPresent++
}
if hasItemID {
formsPresent++
}
if hasIssueRef {
formsPresent++
}
switch {
case formsPresent == 0:
return fmt.Errorf("each item requires exactly one of node_id, item_id, or item_owner + item_repo + issue_number")
case formsPresent > 1:
return fmt.Errorf("each item must set exactly one of node_id, item_id, or item_owner + item_repo + issue_number, not more than one")
}
switch {
case hasNodeID:
s, ok := entry["node_id"].(string)
if !ok || s == "" {
return fmt.Errorf("node_id must be a non-empty string")
}
p.refKind = batchRefNodeID
p.nodeID = s
case hasItemID:
id, err := validatePositiveInt64(entry["item_id"])
if err != nil {
return fmt.Errorf("item_id: %w", err)
}
p.refKind = batchRefItemID
p.itemID = id
default:
issueOwner, ownerErr := stringFromEntry(entry, "item_owner")
issueRepo, repoErr := stringFromEntry(entry, "item_repo")
issueNumber, numErr := intFromEntry(entry, "issue_number")
for _, err := range []error{ownerErr, repoErr, numErr} {
if err != nil {
return fmt.Errorf("item_owner, item_repo, and issue_number must all be provided together: %w", err)
}
}
p.refKind = batchRefIssue
p.issueOwner = issueOwner
p.issueRepo = issueRepo
p.issueNumber = issueNumber
}
return nil
}
func itemRefEcho(entry map[string]any) map[string]any {
ref := map[string]any{}
for _, key := range []string{"node_id", "item_id", "item_owner", "item_repo", "issue_number"} {
if v, ok := entry[key]; ok {
ref[key] = v
}
}
if len(ref) == 0 {
return nil
}
return ref
}
func stringFromEntry(entry map[string]any, key string) (string, error) {
v, ok := entry[key]
if !ok {
return "", fmt.Errorf("missing %s", key)
}
s, ok := v.(string)
if !ok || s == "" {
return "", fmt.Errorf("%s must be a non-empty string", key)
}
return s, nil
}
func intFromEntry(entry map[string]any, key string) (int, error) {
v, ok := entry[key]
if !ok {
return 0, fmt.Errorf("missing %s", key)
}
n, err := validatePositiveInt64(v)
if err != nil {
return 0, fmt.Errorf("%s must be a positive integer: %w", key, err)
}
if n > math.MaxInt32 {
return 0, fmt.Errorf("%s exceeds the GraphQL Int maximum of %d", key, int64(math.MaxInt32))
}
return int(n), nil
}
func validatePositiveInt64(value any) (int64, error) {
n, err := validateAndConvertToInt64(value)
if err != nil {
return 0, err
}
if n <= 0 {
return 0, fmt.Errorf("value must be greater than zero (got %d)", n)
}
return n, nil
}
type batchFieldSpec struct {
id int64
name string
value any
}
func parseBatchFieldSpec(raw any) (batchFieldSpec, error) {
var spec batchFieldSpec
input, ok := raw.(map[string]any)
if !ok || input == nil {
return spec, fmt.Errorf("updated_field must be an object")
}
value, hasValue := input["value"]
if !hasValue {
return spec, fmt.Errorf("updated_field.value is required")
}
spec.value = value
idField, hasID := input["id"]
nameField, hasName := input["name"]
switch {
case hasID && hasName:
return spec, fmt.Errorf("updated_field must set either id or name, not both")
case !hasID && !hasName:
return spec, fmt.Errorf("updated_field requires either id or name")
case hasID:
id, err := validatePositiveInt64(idField)
if err != nil {
return spec, fmt.Errorf("updated_field.id: %w", err)
}
spec.id = id
default:
name, ok := nameField.(string)
if !ok || name == "" {
return spec, fmt.Errorf("updated_field.name must be a non-empty string")
}
spec.name = name
}
return spec, nil
}
func resolveBatchProjectField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, spec batchFieldSpec) (*ResolvedField, error) {
if spec.name != "" {
return resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "")
}
fields, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return nil, err
}
id := fmt.Sprintf("%d", spec.id)
for _, field := range fields {
if field.ID == id {
return &field, nil
}
}
return nil, ghErrors.NewStructuredResolutionError(
"field_not_found",
id,
fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber),
projectFieldCandidates(fields),
)
}
func projectFieldCandidates(fields []ResolvedField) []any {
candidates := make([]any, 0, len(fields))
for _, field := range fields {
candidates = append(candidates, map[string]any{
"id": field.ID,
"name": field.Name,
"data_type": field.DataType,
})
}
return candidates
}
func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) {
var zero githubv4.ProjectV2FieldValue
switch field.DataType {
case "TEXT":
s, ok := raw.(string)
if !ok {
return zero, fmt.Errorf("field %q is TEXT; value must be a string", field.Name)
}
v := githubv4.String(s)
return githubv4.ProjectV2FieldValue{Text: &v}, nil
case "NUMBER":
f, ok := toFloat64(raw)
if !ok {
return zero, fmt.Errorf("field %q is NUMBER; value must be a number", field.Name)
}
v := githubv4.Float(f)
return githubv4.ProjectV2FieldValue{Number: &v}, nil
case "DATE":
s, ok := raw.(string)
if !ok {
return zero, fmt.Errorf("field %q is DATE; value must be a YYYY-MM-DD string", field.Name)
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return zero, fmt.Errorf("field %q is DATE; value %q is not in YYYY-MM-DD format: %w", field.Name, s, err)
}
return githubv4.ProjectV2FieldValue{Date: &githubv4.Date{Time: t}}, nil
case "SINGLE_SELECT":
s, ok := raw.(string)
if !ok || s == "" {
return zero, fmt.Errorf("field %q is SINGLE_SELECT; value must be a non-empty string (option name or ID)", field.Name)
}
optID := s
if resolvedID, optErr := resolveSingleSelectOptionByName(field, s); optErr == nil {
optID = resolvedID
} else {
known := false
for _, opt := range field.Options {
if opt.ID == s {
known = true
break
}
}
if !known {
return zero, optErr
}
}
v := githubv4.String(optID)
return githubv4.ProjectV2FieldValue{SingleSelectOptionID: &v}, nil
case "ITERATION":
s, ok := raw.(string)
if !ok || s == "" {
return zero, fmt.Errorf("field %q is ITERATION; value must be a non-empty iteration ID string", field.Name)
}
v := githubv4.String(s)
return githubv4.ProjectV2FieldValue{IterationID: &v}, nil
default:
return zero, fmt.Errorf("field %q has unsupported data type %q for update_project_items; use update_project_item instead", field.Name, field.DataType)
}
}
func toFloat64(raw any) (float64, bool) {
var number float64
switch v := raw.(type) {
case float64:
number = v
case int:
number = float64(v)
case int64:
number = float64(v)
default:
return 0, false
}
if math.IsNaN(number) || math.IsInf(number, 0) {
return 0, false
}
return number, true
}
type itemLookupResult struct {
nodeID string
fullDatabaseID int64
err error
}
// Numeric lookups are deduplicated and concurrency-bounded; individual failures
// remain isolated while cancellation stops pending work.
func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, ids []int64) map[int64]itemLookupResult {
seen := make(map[int64]struct{}, len(ids))
var unique []int64
for _, id := range ids {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
out := make(map[int64]itemLookupResult, len(unique))
if len(unique) == 0 {
return out
}
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, batchItemLookupConcurrency)
for _, id := range unique {
wg.Add(1)
go func(id int64) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
mu.Lock()
out[id] = itemLookupResult{err: ctx.Err()}
mu.Unlock()
return
}
defer func() { <-sem }()
if ctx.Err() != nil {
mu.Lock()
out[id] = itemLookupResult{err: ctx.Err()}
mu.Unlock()
return
}
var item *github.ProjectV2Item
var err error
if ownerType == "org" {
item, _, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, id, nil)
} else {
item, _, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, id, nil)
}
var res itemLookupResult
switch {
case err != nil:
res = itemLookupResult{err: fmt.Errorf("project item %d: %w", id, err)}
case item == nil || item.NodeID == nil || *item.NodeID == "":
res = itemLookupResult{err: fmt.Errorf("project item %d: response did not include a node id", id)}
default:
res = itemLookupResult{nodeID: *item.NodeID, fullDatabaseID: id}
}
mu.Lock()
out[id] = res
mu.Unlock()
}(id)
}
wg.Wait()
return out
}
type issueRefKey struct {
owner string
repo string
number int
}
func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult {
seen := make(map[issueRefKey]struct{}, len(items))
var unique []issueRefKey
for _, it := range items {
if it.err != nil || it.refKind != batchRefIssue {
continue
}
key := issueRefKey{owner: it.issueOwner, repo: it.issueRepo, number: it.issueNumber}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
unique = append(unique, key)
}
out := make(map[issueRefKey]itemLookupResult, len(unique))
if len(unique) == 0 {
return out
}
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, batchItemLookupConcurrency)
for _, key := range unique {
wg.Add(1)
go func(key issueRefKey) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
mu.Lock()
out[key] = itemLookupResult{err: ctx.Err()}
mu.Unlock()
return
}
defer func() { <-sem }()
if ctx.Err() != nil {
mu.Lock()
out[key] = itemLookupResult{err: ctx.Err()}
mu.Unlock()
return
}
nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, key.owner, key.repo, key.number)
mu.Lock()
out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err}
mu.Unlock()
}(key)
}
wg.Wait()
return out
}
func batchErrorFromResolution(err error) *batchItemError {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return &batchItemError{
Code: structured.Kind,
Message: fmt.Sprintf("%s: %s", structured.Kind, structured.Name),
Hint: structured.Hint,
Candidates: structured.Candidates,
}
}
return &batchItemError{
Code: "resolution_failed",
Message: err.Error(),
}
}