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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user