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
This commit is contained in:
Lizeth Vera
2026-07-29 12:57:26 -07:00
committed by GitHub
parent 456fae9d04
commit d080b23f59
6 changed files with 2602 additions and 8 deletions
+2 -1
View File
@@ -1123,6 +1123,7 @@ The following sets of tools are available:
- `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
- `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
- `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional)
- `items`: The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 50 items per call. (object[], optional)
- `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional)
- `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], optional)
- `method`: The method to execute (string, required)
@@ -1134,7 +1135,7 @@ The following sets of tools are available:
- `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional)
- `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional)
- `title`: The project title. Required for 'create_project' method. (string, optional)
- `updated_field`: Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {"id": 123456, "value": "..."}; (2) by name — {"name": "Status", "value": "In Progress"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field. (object, optional)
- `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID. (object, optional)
</details>
+97 -2
View File
@@ -5,7 +5,7 @@
"readOnlyHint": false,
"title": "Manage GitHub Projects"
},
"description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields.",
"description": "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, create status updates, and add iteration fields.",
"inputSchema": {
"properties": {
"body": {
@@ -40,6 +40,64 @@
],
"type": "string"
},
"items": {
"description": "The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 50 items per call.",
"items": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"node_id": {
"description": "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
"type": "string"
}
},
"required": [
"node_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"item_id": {
"description": "The numeric project item ID.",
"type": "integer"
}
},
"required": [
"item_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"issue_number": {
"description": "Issue number used to resolve the project item.",
"type": "integer"
},
"item_owner": {
"description": "Owner of the repository containing the issue.",
"type": "string"
},
"item_repo": {
"description": "Repository containing the issue.",
"type": "string"
}
},
"required": [
"item_owner",
"item_repo",
"issue_number"
],
"type": "object"
}
],
"type": "object"
},
"type": "array"
},
"iteration_duration": {
"description": "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.",
"type": "number"
@@ -76,6 +134,7 @@
"enum": [
"add_project_item",
"update_project_item",
"update_project_items",
"delete_project_item",
"create_project_status_update",
"create_project",
@@ -127,7 +186,43 @@
"type": "string"
},
"updated_field": {
"description": "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.",
"description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"id": {
"description": "The numeric project field ID.",
"type": "integer"
},
"value": {
"description": "The value to apply. Any JSON value is accepted; use null to clear the field."
}
},
"required": [
"id",
"value"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"name": {
"description": "The project field name. Matching is case-insensitive.",
"type": "string"
},
"value": {
"description": "The value to apply. Any JSON value is accepted; use null to clear the field."
}
},
"required": [
"name",
"value"
],
"type": "object"
}
],
"type": "object"
}
},
+88 -4
View File
@@ -32,6 +32,7 @@ const (
ProjectStatusUpdateCreateFailedError = "failed to create project status update"
ProjectResolveIDFailedError = "failed to resolve project ID"
MaxProjectsPerPage = 50
maxProjectItemsPerBatch = 50
)
// Method constants for consolidated project tools
@@ -44,6 +45,7 @@ const (
projectsMethodGetProjectItem = "get_project_item"
projectsMethodAddProjectItem = "add_project_item"
projectsMethodUpdateProjectItem = "update_project_item"
projectsMethodUpdateProjectItems = "update_project_items"
projectsMethodDeleteProjectItem = "delete_project_item"
projectsMethodListProjectStatusUpdates = "list_project_status_updates"
projectsMethodGetProjectStatusUpdate = "get_project_status_update"
@@ -490,13 +492,90 @@ Use this tool to get details about individual projects, project fields, and proj
return tool
}
func updateProjectItemsItemSchema() *jsonschema.Schema {
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}
return &jsonschema.Schema{
Type: "object",
OneOf: []*jsonschema.Schema{
variant([]string{"node_id"}, map[string]*jsonschema.Schema{
"node_id": {
Type: "string",
Description: "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
},
}),
variant([]string{"item_id"}, map[string]*jsonschema.Schema{
"item_id": {
Type: "integer",
Description: "The numeric project item ID.",
},
}),
variant([]string{"item_owner", "item_repo", "issue_number"}, map[string]*jsonschema.Schema{
"item_owner": {
Type: "string",
Description: "Owner of the repository containing the issue.",
},
"item_repo": {
Type: "string",
Description: "Repository containing the issue.",
},
"issue_number": {
Type: "integer",
Description: "Issue number used to resolve the project item.",
},
}),
},
}
}
func projectUpdatedFieldSchema() *jsonschema.Schema {
value := &jsonschema.Schema{
Description: "The value to apply. Any JSON value is accepted; use null to clear the field.",
}
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
properties["value"] = value
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}
return &jsonschema.Schema{
Type: "object",
Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.",
OneOf: []*jsonschema.Schema{
variant([]string{"id", "value"}, map[string]*jsonschema.Schema{
"id": {
Type: "integer",
Description: "The numeric project field ID.",
},
}),
variant([]string{"name", "value"}, map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "The project field name. Matching is case-insensitive.",
},
}),
},
}
}
// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources.
func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_write",
Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."),
Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, create status updates, and add iteration fields."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"),
ReadOnlyHint: false,
@@ -511,6 +590,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Enum: []any{
projectsMethodAddProjectItem,
projectsMethodUpdateProjectItem,
projectsMethodUpdateProjectItems,
projectsMethodDeleteProjectItem,
projectsMethodCreateProjectStatusUpdate,
projectsMethodCreateProject,
@@ -559,9 +639,11 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Type: "number",
Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
},
"updated_field": {
Type: "object",
Description: "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.",
"updated_field": projectUpdatedFieldSchema(),
"items": {
Type: "array",
Description: "The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: " + strconv.Itoa(maxProjectItemsPerBatch) + " items per call.",
Items: updateProjectItemsItemSchema(),
},
"body": {
Type: "string",
@@ -722,6 +804,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError("updated_field must be an object"), nil, nil
}
return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue)
case projectsMethodUpdateProjectItems:
return updateProjectItemsBatch(ctx, client, gqlClient, owner, ownerType, projectNumber, args)
case projectsMethodDeleteProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
+812
View File
@@ -0,0 +1,812 @@
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(),
}
}
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -866,7 +866,7 @@ func Test_ProjectsWrite(t *testing.T) {
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
assert.Equal(t, "projects_write", toolDef.Tool.Name)
assert.NotEmpty(t, toolDef.Tool.Description)
assert.Contains(t, toolDef.Tool.Description, "bulk-update many items at once")
inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, inputSchema.Properties, "method")
assert.Contains(t, inputSchema.Properties, "owner")
@@ -879,6 +879,7 @@ func Test_ProjectsWrite(t *testing.T) {
assert.Contains(t, inputSchema.Properties, "issue_number")
assert.Contains(t, inputSchema.Properties, "pull_request_number")
assert.Contains(t, inputSchema.Properties, "updated_field")
assert.Contains(t, inputSchema.Properties, "items")
assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"})
// Verify DestructiveHint is set
@@ -887,6 +888,64 @@ func Test_ProjectsWrite(t *testing.T) {
assert.True(t, *toolDef.Tool.Annotations.DestructiveHint)
}
func Test_ProjectsWrite_UpdateProjectItemsSchema(t *testing.T) {
inputSchema := ProjectsWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, inputSchema.Properties["items"].Description, "prefer it over calling 'update_project_item' in a loop")
itemSchema := inputSchema.Properties["items"].Items
assert.Equal(t, "object", itemSchema.Type)
assert.Empty(t, itemSchema.Properties, "item references should be modeled by oneOf, not flattened properties")
require.Len(t, itemSchema.OneOf, 3)
expectedRequired := [][]string{
{"node_id"},
{"item_id"},
{"item_owner", "item_repo", "issue_number"},
}
expectedProperties := [][]string{
{"node_id"},
{"item_id"},
{"item_owner", "item_repo", "issue_number"},
}
for i, variant := range itemSchema.OneOf {
properties := make([]string, 0, len(variant.Properties))
for name := range variant.Properties {
properties = append(properties, name)
}
assert.Equal(t, "object", variant.Type)
assert.ElementsMatch(t, expectedRequired[i], variant.Required)
assert.ElementsMatch(t, expectedProperties[i], properties)
for _, property := range variant.Properties {
assert.NotEmpty(t, property.Type)
assert.NotEmpty(t, property.Description)
}
require.NotNil(t, variant.AdditionalProperties)
assert.NotNil(t, variant.AdditionalProperties.Not, "variant must reject additional properties")
}
fieldSchema := inputSchema.Properties["updated_field"]
assert.Equal(t, "object", fieldSchema.Type)
assert.Contains(t, fieldSchema.Description, "one top-level field/value applies to every item")
require.Len(t, fieldSchema.OneOf, 2)
for i, variant := range fieldSchema.OneOf {
reference := "id"
if i == 1 {
reference = "name"
}
properties := make([]string, 0, len(variant.Properties))
for name := range variant.Properties {
properties = append(properties, name)
}
assert.ElementsMatch(t, []string{reference, "value"}, variant.Required)
assert.ElementsMatch(t, []string{reference, "value"}, properties)
require.NotNil(t, variant.AdditionalProperties)
assert.NotNil(t, variant.AdditionalProperties.Not)
assert.Empty(t, variant.Properties["value"].Type, "an unconstrained value schema accepts any JSON value, including null")
assert.Empty(t, variant.Properties["value"].Types)
assert.NotEmpty(t, variant.Properties["value"].Description)
}
}
func Test_ProjectsWrite_AddProjectItem(t *testing.T) {
toolDef := ProjectsWrite(translations.NullTranslationHelper)