From 50f0d4215be8d502b5484673af90d90e6e934b37 Mon Sep 17 00:00:00 2001 From: Lizeth Vera <47796851+veralizeth@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:36:22 +0000 Subject: [PATCH 01/11] Adding an initial draft of batch/bulk field-value writes --- README.md | 3 +- pkg/github/__toolsnaps__/projects_write.snap | 33 +- pkg/github/projects.go | 422 ++++++++++++++++++- pkg/github/projects_resolver_test.go | 203 +++++++++ pkg/github/projects_test.go | 236 +++++++++++ 5 files changed, 893 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9a6ef3677a..bbbcfc2590 100644 --- a/README.md +++ b/README.md @@ -1119,6 +1119,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 set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 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) @@ -1130,7 +1131,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`: Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 762ee08c93..c742b68ed9 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -40,6 +40,36 @@ ], "type": "string" }, + "items": { + "description": "The set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call.", + "items": { + "additionalProperties": false, + "properties": { + "issue_number": { + "description": "Issue number (or pull request number) used to resolve the project item together with item_owner and item_repo.", + "type": "number" + }, + "item_id": { + "description": "The project item ID. Provide either item_id, or (item_owner + item_repo + issue_number).", + "type": "number" + }, + "item_owner": { + "description": "Owner of the repository containing the issue or pull request. Combine with item_repo and issue_number to resolve the item.", + "type": "string" + }, + "item_repo": { + "description": "Repository containing the issue or pull request. Combine with item_owner and issue_number to resolve the item.", + "type": "string" + }, + "updated_field": { + "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + "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 +106,7 @@ "enum": [ "add_project_item", "update_project_item", + "update_project_items", "delete_project_item", "create_project_status_update", "create_project", @@ -127,7 +158,7 @@ "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": "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'.", "type": "object" } }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 308c2b87e8..2df6921d99 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -32,6 +33,10 @@ const ( ProjectStatusUpdateCreateFailedError = "failed to create project status update" ProjectResolveIDFailedError = "failed to resolve project ID" MaxProjectsPerPage = 50 + // MaxProjectItemsPerBatch caps the number of items processed in a single + // update_project_items call. This keeps a batch bounded, prevents runaway + // resolution costs, and matches the practical scale of typical triage sweeps. + MaxProjectItemsPerBatch = 100 ) // Method constants for consolidated project tools @@ -44,6 +49,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" @@ -511,6 +517,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Enum: []any{ projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, + projectsMethodUpdateProjectItems, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, projectsMethodCreateProject, @@ -561,7 +568,37 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "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.", + Description: "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'.", + }, + "items": { + Type: "array", + Description: "The set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "item_id": { + Type: "number", + Description: "The project item ID. Provide either item_id, or (item_owner + item_repo + issue_number).", + }, + "item_owner": { + Type: "string", + Description: "Owner of the repository containing the issue or pull request. Combine with item_repo and issue_number to resolve the item.", + }, + "item_repo": { + Type: "string", + Description: "Repository containing the issue or pull request. Combine with item_owner and issue_number to resolve the item.", + }, + "issue_number": { + Type: "number", + Description: "Issue number (or pull request number) used to resolve the project item together with item_owner and item_repo.", + }, + "updated_field": { + Type: "object", + Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + }, + }, + }, }, "body": { Type: "string", @@ -722,6 +759,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 { @@ -1193,6 +1232,298 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultText(string(r)), nil, nil } +// batchItemResult is the per-item outcome returned by update_project_items. +// On success, Item is populated. On failure, Error carries a stable machine-readable +// code (mirroring the StructuredResolutionError shape where relevant) plus a human +// message so agents can self-correct without failing the whole batch. +type batchItemResult struct { + Index int `json:"index"` + OK bool `json:"ok"` + Item *MinimalProjectItem `json:"item,omitempty"` + Error *batchItemError `json:"error,omitempty"` + // Ref echoes the identifying input for the item so agents can correlate results + // with the request even when resolution fails (before item_id is known). + Ref map[string]any `json:"ref,omitempty"` +} + +type batchItemError struct { + Code string `json:"code"` + Message string `json:"message"` + Candidates []any `json:"candidates,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// updateProjectItemsBatch handles the update_project_items method. It applies one or +// more field updates in a single tool call, with resolution amortised across the +// batch and per-item error mapping. See docs/tool-renaming.md for the shape. +// +// Current transport: N sequential REST calls (mirroring update_project_item) with a +// shared, single-round-trip field-name cache and a single owner-type detection. The +// underlying `updateProjectV2ItemFieldValue` GraphQL mutation supports request +// aliasing (multiple mutations per HTTP round trip); once the pinned +// shurcooL/githubv4 client supports dynamic aliased mutations we can swap the loop +// out for a chunked aliased request without changing the tool contract. +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 + } + + // Optional top-level updated_field, applied to every item that does not + // supply its own updated_field. + var sharedField map[string]any + if rawShared, hasShared := args["updated_field"]; hasShared { + if m, ok := rawShared.(map[string]any); ok && m != nil { + sharedField = m + } else { + return utils.NewToolResultError("updated_field must be an object"), nil, nil + } + } + + cache := newProjectFieldsCache() + results := make([]batchItemResult, len(itemsRaw)) + + for i, raw := range itemsRaw { + entry, ok := raw.(map[string]any) + if !ok || entry == nil { + results[i] = batchItemResult{ + Index: i, + OK: false, + Error: &batchItemError{ + Code: "invalid_item", + Message: fmt.Sprintf("items[%d] must be an object", i), + }, + } + continue + } + + ref := itemRefEcho(entry) + + // Resolve the effective updated_field: per-item override wins over shared. + effectiveField, fieldErr := effectiveUpdatedField(entry, sharedField) + if fieldErr != nil { + results[i] = batchItemResult{ + Index: i, + OK: false, + Ref: ref, + Error: &batchItemError{ + Code: "invalid_updated_field", + Message: fieldErr.Error(), + }, + } + continue + } + + // Resolve item ID: either provided directly or resolved from issue ref. + itemID, resolveErr := resolveBatchItemID(ctx, gqlClient, owner, ownerType, projectNumber, entry) + if resolveErr != nil { + results[i] = batchItemResult{ + Index: i, + OK: false, + Ref: ref, + Error: batchErrorFromResolution(resolveErr), + } + continue + } + + payload, buildErr := buildUpdateProjectItemWithCache(ctx, gqlClient, cache, owner, ownerType, projectNumber, effectiveField) + if buildErr != nil { + results[i] = batchItemResult{ + Index: i, + OK: false, + Ref: ref, + Error: batchErrorFromResolution(buildErr), + } + continue + } + + var ( + resp *github.Response + updatedItem *github.ProjectV2Item + err error + ) + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, payload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, payload) + } + if err != nil { + results[i] = batchItemResult{ + Index: i, + OK: false, + Ref: ref, + Error: &batchItemError{ + Code: "update_failed", + Message: fmt.Sprintf("%s: %v", ProjectUpdateFailedError, err), + }, + } + if resp != nil { + _ = resp.Body.Close() + } + continue + } + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) + } + results[i] = batchItemResult{ + Index: i, + OK: false, + Ref: ref, + Error: &batchItemError{ + Code: "update_failed", + Message: fmt.Sprintf("%s: status %d: %s", ProjectUpdateFailedError, resp.StatusCode, strings.TrimSpace(string(body))), + }, + } + continue + } + _ = resp.Body.Close() + + item := convertToMinimalProjectItem(updatedItem) + results[i] = batchItemResult{ + Index: i, + OK: true, + Ref: ref, + Item: &item, + } + } + + success := 0 + for _, r := range results { + if r.OK { + success++ + } + } + + response := map[string]any{ + "total": len(results), + "succeeded": success, + "failed": len(results) - success, + "results": results, + } + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +// effectiveUpdatedField picks the per-item updated_field when set, otherwise falls +// back to the shared top-level value. Returns an error if neither is present or the +// per-item override is malformed. +func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) { + if raw, ok := entry["updated_field"]; ok { + m, isMap := raw.(map[string]any) + if !isMap || m == nil { + return nil, fmt.Errorf("updated_field must be an object") + } + return m, nil + } + if shared != nil { + return shared, nil + } + return nil, fmt.Errorf("updated_field is required either at the top level or on each item") +} + +// itemRefEcho returns a copy of the identifying fields of an item entry, used to +// help agents correlate per-item results with their inputs. +func itemRefEcho(entry map[string]any) map[string]any { + ref := map[string]any{} + for _, key := range []string{"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 +} + +// resolveBatchItemID resolves a single batch entry to a project item ID. Accepts +// either an explicit item_id or an (item_owner, item_repo, issue_number) triplet +// (same semantics as update_project_item's single-item path). +func resolveBatchItemID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, entry map[string]any) (int64, error) { + if _, has := entry["item_id"]; has { + id, err := validateAndConvertToInt64(entry["item_id"]) + if err != nil { + return 0, fmt.Errorf("item_id: %w", err) + } + return id, nil + } + + issueOwner, ownerErr := stringFromEntry(entry, "item_owner") + issueRepo, repoErr := stringFromEntry(entry, "item_repo") + issueNumber, numErr := intFromEntry(entry, "issue_number") + if ownerErr != nil || repoErr != nil || numErr != nil { + return 0, fmt.Errorf("each item requires either item_id, or item_owner + item_repo + issue_number") + } + if gqlClient == nil { + return 0, fmt.Errorf("internal error: gqlClient is required to resolve items by issue number") + } + return resolveProjectItemIDByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) +} + +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) + } + switch n := v.(type) { + case float64: + return int(n), nil + case int: + return n, nil + case int64: + return int(n), nil + default: + return 0, fmt.Errorf("%s must be a number", key) + } +} + +// batchErrorFromResolution converts an error from resolution/build helpers into a +// batch-item error. Structured resolution errors preserve their code, hint, and +// candidates so agents can self-correct per-item without failing the batch. +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: "invalid_argument", + Message: err.Error(), + } +} + func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error @@ -1530,8 +1861,91 @@ func validateAndConvertToInt64(value any) (int64, error) { } } +// projectFieldsCache lazily loads every field on a project once, so callers that +// need to resolve multiple field names against the same project pay a single +// GraphQL round trip instead of one per name. +type projectFieldsCache struct { + loaded bool + fields []ResolvedField + byName map[string][]ResolvedField +} + +func newProjectFieldsCache() *projectFieldsCache { + return &projectFieldsCache{} +} + +func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) error { + if c.loaded { + return nil + } + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return err + } + c.fields = all + c.byName = make(map[string][]ResolvedField, len(all)) + for _, f := range all { + key := strings.ToLower(f.Name) + c.byName[key] = append(c.byName[key], f) + } + c.loaded = true + return nil +} + +// resolveFieldByName is a cached variant of resolveProjectFieldByName: it uses the +// cache's in-memory index instead of querying GraphQL per lookup. +func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName string) (*ResolvedField, error) { + if fieldName == "" { + return nil, fmt.Errorf("field name must not be empty") + } + if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { + return nil, err + } + matches := c.byName[strings.ToLower(fieldName)] + switch len(matches) { + case 0: + candidates := make([]any, 0, len(c.fields)) + for _, f := range c.fields { + candidates = append(candidates, map[string]any{ + "name": f.Name, + "data_type": f.DataType, + }) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + fieldName, + fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), + candidates, + ) + case 1: + f := matches[0] + return &f, nil + default: + candidates := make([]any, 0, len(matches)) + for _, f := range matches { + candidates = append(candidates, map[string]any{ + "id": f.ID, + "data_type": f.DataType, + }) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_ambiguous", + fieldName, + "multiple fields share this name; pass updated_field.id to disambiguate", + candidates, + ) + } +} + // buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { + return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input) +} + +// buildUpdateProjectItemWithCache is the cache-aware variant of buildUpdateProjectItem. +// When cache is non-nil, name-based field lookups reuse the cache's in-memory index; when +// nil, each name lookup performs its own GraphQL round trip (single-item behaviour). +func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } @@ -1571,7 +1985,11 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") } var err error - resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") + if cache != nil { + resolved, err = cache.resolveFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName) + } else { + resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") + } if err != nil { return nil, err } diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index b08e00cac6..8fa894640e 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -803,3 +803,206 @@ func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testin assert.Equal(t, "field_not_found", msg["error"]) assert.Equal(t, "Doesnt Exist", msg["name"]) } + +// Test_ProjectsWrite_UpdateProjectItems_Batch_ByNameAndIssueRef exercises the +// batch method's happy-path resolvers: one item is addressed by numeric item_id, +// another by (item_owner, item_repo, issue_number). Field-name resolution runs +// once for the entire batch. +func Test_ProjectsWrite_UpdateProjectItems_Batch_ByNameAndIssueRef(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := verbosePullRequestProjectItemFixture() + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + + mockedGQL := githubv4mock.NewMockedHTTPClient( + // Project node id resolution (used to filter the issue's project items). + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": "PVT_project1"}, + }, + }), + ), + // Issue -> projectItems traversal for the issue-ref item. + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("github"), + "issueRepo": githubv4.String("planning-tracking"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "2002", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), + // One-shot field-name resolution shared across the batch. + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ + {"id": "OPT_in_progress", "name": "In Progress"}, + }), + })), + ), + ) + gqlClient := githubv4.NewClient(mockedGQL) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{ + "name": "Status", + "value": "In Progress", + }, + "items": []any{ + map[string]any{"item_id": float64(1001)}, + map[string]any{ + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": float64(123), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(2), response["succeeded"]) + assert.Equal(t, float64(0), response["failed"]) +} + +// Test_ProjectsWrite_UpdateProjectItems_Batch_PartialFailure_ItemNotInProject +// verifies that when one item's issue is not on the project, its per-item error +// is a structured item_not_in_project code and does not abort the batch. +func Test_ProjectsWrite_UpdateProjectItems_Batch_PartialFailure_ItemNotInProject(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := verbosePullRequestProjectItemFixture() + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + + mockedGQL := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": "PVT_project1"}, + }, + }), + ), + // Issue exists but its projectItems do NOT include our project. + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("github"), + "issueRepo": githubv4.String("planning-tracking"), + "issueNumber": githubv4.Int(999), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + gqlClient := githubv4.NewClient(mockedGQL) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + "items": []any{ + map[string]any{"item_id": float64(1001)}, + map[string]any{ + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": float64(999), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) + + results := response["results"].([]any) + require.Len(t, results, 2) + failed := results[1].(map[string]any) + assert.Equal(t, false, failed["ok"]) + errObj := failed["error"].(map[string]any) + assert.Equal(t, "item_not_in_project", errObj["code"]) + // The ref should echo the identifying issue-ref inputs so agents can correlate. + ref := failed["ref"].(map[string]any) + assert.Equal(t, "github", ref["item_owner"]) + assert.Equal(t, "planning-tracking", ref["item_repo"]) + assert.Equal(t, float64(999), ref["issue_number"]) +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 553c2421a4..09e99fa61e 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -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 @@ -1224,6 +1225,241 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { }) } +func Test_ProjectsWrite_UpdateProjectItems_Batch(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success with shared updated_field applied to two items", func(t *testing.T) { + updatedItem := verbosePullRequestProjectItemFixture() + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + // No GraphQL calls expected: both entries use item_id + updated_field.id, so + // no name resolution or issue-number lookup is needed. + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + "items": []any{ + map[string]any{"item_id": float64(1001)}, + map[string]any{"item_id": float64(1002)}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(2), response["total"]) + assert.Equal(t, float64(2), response["succeeded"]) + assert.Equal(t, float64(0), response["failed"]) + results, ok := response["results"].([]any) + require.True(t, ok) + require.Len(t, results, 2) + for _, r := range results { + entry := r.(map[string]any) + assert.Equal(t, true, entry["ok"]) + assert.Nil(t, entry["error"]) + assert.NotNil(t, entry["item"]) + } + }) + + t.Run("resolves field name once for the whole batch", func(t *testing.T) { + updatedItem := verbosePullRequestProjectItemFixture() + + // Count fields(...) calls to confirm we only resolve once for the batch. + var fieldCallCount int32 + fieldsHandler := &countingHTTPHandler{count: &fieldCallCount} + fieldsHandler.matchers = []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ + {"id": "OPT_in_progress", "name": "In Progress"}, + }), + })), + ), + } + gqlClient := githubv4.NewClient(&http.Client{Transport: fieldsHandler}) + + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{ + "name": "Status", + "value": "In Progress", + }, + "items": []any{ + map[string]any{"item_id": float64(1001)}, + map[string]any{"item_id": float64(1002)}, + map[string]any{"item_id": float64(1003)}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + // Exactly one field-resolution round trip regardless of batch size. + assert.Equal(t, int32(1), fieldCallCount) + }) + + t.Run("partial failure: one item resolves, one has an invalid updated_field", func(t *testing.T) { + updatedItem := verbosePullRequestProjectItemFixture() + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "items": []any{ + map[string]any{ + "item_id": float64(1001), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + }, + // Missing both id and name on updated_field: should surface as a per-item error. + map[string]any{ + "item_id": float64(1002), + "updated_field": map[string]any{ + "value": "In Progress", + }, + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(2), response["total"]) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) + + results := response["results"].([]any) + require.Len(t, results, 2) + first := results[0].(map[string]any) + second := results[1].(map[string]any) + assert.Equal(t, true, first["ok"]) + assert.Equal(t, false, second["ok"]) + errObj := second["error"].(map[string]any) + assert.Equal(t, "invalid_argument", errObj["code"]) + assert.Contains(t, errObj["message"], "either id or name") + }) + + t.Run("missing items array", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"id": float64(101), "value": "Todo"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: items") + }) + + t.Run("empty items array", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"id": float64(101), "value": "Todo"}, + "items": []any{}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "at least one entry") + }) + + t.Run("item with no updated_field and no shared updated_field is a per-item error", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "items": []any{ + map[string]any{"item_id": float64(1001)}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(0), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) + results := response["results"].([]any) + errObj := results[0].(map[string]any)["error"].(map[string]any) + assert.Equal(t, "invalid_updated_field", errObj["code"]) + }) +} + +// countingHTTPHandler wraps a set of githubv4mock matchers with a request counter. +// It behaves like githubv4mock.NewMockedHTTPClient's transport but exposes a hit +// count so tests can assert on the number of round trips. +type countingHTTPHandler struct { + matchers []githubv4mock.Matcher + count *int32 +} + +func (c *countingHTTPHandler) RoundTrip(req *http.Request) (*http.Response, error) { + client := githubv4mock.NewMockedHTTPClient(c.matchers...) + resp, err := client.Transport.RoundTrip(req) + if resp != nil && resp.StatusCode == http.StatusOK { + *c.count++ + } + return resp, err +} + func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) From 2c3a15039313d994fcaceca2c936474bd33f83c8 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Mon, 20 Jul 2026 16:26:00 -0400 Subject: [PATCH 02/11] Replace update_project_items REST loop with chunked aliased mutations Rewire update_project_items to resolve the project, every distinct field, and every item once up front, then write via the projects_batch_mutation.go aliased-mutation builder, chunked into sequential requests of 20, instead of one REST PATCH per item. - projects_batch_resolve.go: field/item resolution. Adds node_id as a third item-reference form (alongside item_id and item_owner+item_repo+issue_number, exactly one required per item), bypassing REST lookup entirely. Numeric item_id values are deduplicated and resolved to node IDs via bounded-concurrency REST GETs; issue refs are deduplicated and resolved via the paginated resolver from the previous commit. updated_field.value is converted to the matching ProjectV2FieldValue member for TEXT, NUMBER, DATE, SINGLE_SELECT, and ITERATION; null routes to the clear mutation. - projects_batch.go: orchestration. Rejects duplicate item+field targets before any writes, partitions into update/clear chunks, and tracks tri-state per-item results (succeeded/failed/unknown). After an ambiguous transport-level failure or context cancellation, no further chunks are sent. - projects.go: removed the old REST-loop implementation and its helpers; added node_id to the update_project_items item schema. - Removed the stale comment claiming the pinned client couldn't build dynamic aliases. - Updated/added tests accordingly, including toolsnap and README regeneration for the schema change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 8 +- pkg/github/projects.go | 377 +---------- pkg/github/projects_batch.go | 364 +++++++++++ pkg/github/projects_batch_e2e_test.go | 646 +++++++++++++++++++ pkg/github/projects_batch_resolve.go | 577 +++++++++++++++++ pkg/github/projects_batch_resolve_test.go | 243 +++++++ pkg/github/projects_resolver_test.go | 203 ------ pkg/github/projects_test.go | 235 ------- 9 files changed, 1843 insertions(+), 812 deletions(-) create mode 100644 pkg/github/projects_batch.go create mode 100644 pkg/github/projects_batch_e2e_test.go create mode 100644 pkg/github/projects_batch_resolve.go create mode 100644 pkg/github/projects_batch_resolve_test.go diff --git a/README.md b/README.md index bbbcfc2590..0f07bec685 100644 --- a/README.md +++ b/README.md @@ -1119,7 +1119,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 set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call. (object[], optional) + - `items`: The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 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) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index c742b68ed9..bdbe757f8c 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -41,7 +41,7 @@ "type": "string" }, "items": { - "description": "The set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call.", + "description": "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call.", "items": { "additionalProperties": false, "properties": { @@ -50,7 +50,7 @@ "type": "number" }, "item_id": { - "description": "The project item ID. Provide either item_id, or (item_owner + item_repo + issue_number).", + "description": "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", "type": "number" }, "item_owner": { @@ -61,6 +61,10 @@ "description": "Repository containing the issue or pull request. Combine with item_owner and issue_number to resolve the item.", "type": "string" }, + "node_id": { + "description": "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", + "type": "string" + }, "updated_field": { "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", "type": "object" diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 2df6921d99..62b44735ca 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "strconv" - "strings" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -572,14 +571,18 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "items": { Type: "array", - Description: "The set of items to update. Required for 'update_project_items'. Each entry references either an existing item by 'item_id', or by (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", + Description: "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", Items: &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ + "node_id": { + Type: "string", + Description: "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", + }, "item_id": { Type: "number", - Description: "The project item ID. Provide either item_id, or (item_owner + item_repo + issue_number).", + Description: "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", }, "item_owner": { Type: "string", @@ -1232,298 +1235,6 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultText(string(r)), nil, nil } -// batchItemResult is the per-item outcome returned by update_project_items. -// On success, Item is populated. On failure, Error carries a stable machine-readable -// code (mirroring the StructuredResolutionError shape where relevant) plus a human -// message so agents can self-correct without failing the whole batch. -type batchItemResult struct { - Index int `json:"index"` - OK bool `json:"ok"` - Item *MinimalProjectItem `json:"item,omitempty"` - Error *batchItemError `json:"error,omitempty"` - // Ref echoes the identifying input for the item so agents can correlate results - // with the request even when resolution fails (before item_id is known). - Ref map[string]any `json:"ref,omitempty"` -} - -type batchItemError struct { - Code string `json:"code"` - Message string `json:"message"` - Candidates []any `json:"candidates,omitempty"` - Hint string `json:"hint,omitempty"` -} - -// updateProjectItemsBatch handles the update_project_items method. It applies one or -// more field updates in a single tool call, with resolution amortised across the -// batch and per-item error mapping. See docs/tool-renaming.md for the shape. -// -// Current transport: N sequential REST calls (mirroring update_project_item) with a -// shared, single-round-trip field-name cache and a single owner-type detection. The -// underlying `updateProjectV2ItemFieldValue` GraphQL mutation supports request -// aliasing (multiple mutations per HTTP round trip); once the pinned -// shurcooL/githubv4 client supports dynamic aliased mutations we can swap the loop -// out for a chunked aliased request without changing the tool contract. -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 - } - - // Optional top-level updated_field, applied to every item that does not - // supply its own updated_field. - var sharedField map[string]any - if rawShared, hasShared := args["updated_field"]; hasShared { - if m, ok := rawShared.(map[string]any); ok && m != nil { - sharedField = m - } else { - return utils.NewToolResultError("updated_field must be an object"), nil, nil - } - } - - cache := newProjectFieldsCache() - results := make([]batchItemResult, len(itemsRaw)) - - for i, raw := range itemsRaw { - entry, ok := raw.(map[string]any) - if !ok || entry == nil { - results[i] = batchItemResult{ - Index: i, - OK: false, - Error: &batchItemError{ - Code: "invalid_item", - Message: fmt.Sprintf("items[%d] must be an object", i), - }, - } - continue - } - - ref := itemRefEcho(entry) - - // Resolve the effective updated_field: per-item override wins over shared. - effectiveField, fieldErr := effectiveUpdatedField(entry, sharedField) - if fieldErr != nil { - results[i] = batchItemResult{ - Index: i, - OK: false, - Ref: ref, - Error: &batchItemError{ - Code: "invalid_updated_field", - Message: fieldErr.Error(), - }, - } - continue - } - - // Resolve item ID: either provided directly or resolved from issue ref. - itemID, resolveErr := resolveBatchItemID(ctx, gqlClient, owner, ownerType, projectNumber, entry) - if resolveErr != nil { - results[i] = batchItemResult{ - Index: i, - OK: false, - Ref: ref, - Error: batchErrorFromResolution(resolveErr), - } - continue - } - - payload, buildErr := buildUpdateProjectItemWithCache(ctx, gqlClient, cache, owner, ownerType, projectNumber, effectiveField) - if buildErr != nil { - results[i] = batchItemResult{ - Index: i, - OK: false, - Ref: ref, - Error: batchErrorFromResolution(buildErr), - } - continue - } - - var ( - resp *github.Response - updatedItem *github.ProjectV2Item - err error - ) - if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, payload) - } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, payload) - } - if err != nil { - results[i] = batchItemResult{ - Index: i, - OK: false, - Ref: ref, - Error: &batchItemError{ - Code: "update_failed", - Message: fmt.Sprintf("%s: %v", ProjectUpdateFailedError, err), - }, - } - if resp != nil { - _ = resp.Body.Close() - } - continue - } - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if readErr != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) - } - results[i] = batchItemResult{ - Index: i, - OK: false, - Ref: ref, - Error: &batchItemError{ - Code: "update_failed", - Message: fmt.Sprintf("%s: status %d: %s", ProjectUpdateFailedError, resp.StatusCode, strings.TrimSpace(string(body))), - }, - } - continue - } - _ = resp.Body.Close() - - item := convertToMinimalProjectItem(updatedItem) - results[i] = batchItemResult{ - Index: i, - OK: true, - Ref: ref, - Item: &item, - } - } - - success := 0 - for _, r := range results { - if r.OK { - success++ - } - } - - response := map[string]any{ - "total": len(results), - "succeeded": success, - "failed": len(results) - success, - "results": results, - } - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - return utils.NewToolResultText(string(r)), nil, nil -} - -// effectiveUpdatedField picks the per-item updated_field when set, otherwise falls -// back to the shared top-level value. Returns an error if neither is present or the -// per-item override is malformed. -func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) { - if raw, ok := entry["updated_field"]; ok { - m, isMap := raw.(map[string]any) - if !isMap || m == nil { - return nil, fmt.Errorf("updated_field must be an object") - } - return m, nil - } - if shared != nil { - return shared, nil - } - return nil, fmt.Errorf("updated_field is required either at the top level or on each item") -} - -// itemRefEcho returns a copy of the identifying fields of an item entry, used to -// help agents correlate per-item results with their inputs. -func itemRefEcho(entry map[string]any) map[string]any { - ref := map[string]any{} - for _, key := range []string{"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 -} - -// resolveBatchItemID resolves a single batch entry to a project item ID. Accepts -// either an explicit item_id or an (item_owner, item_repo, issue_number) triplet -// (same semantics as update_project_item's single-item path). -func resolveBatchItemID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, entry map[string]any) (int64, error) { - if _, has := entry["item_id"]; has { - id, err := validateAndConvertToInt64(entry["item_id"]) - if err != nil { - return 0, fmt.Errorf("item_id: %w", err) - } - return id, nil - } - - issueOwner, ownerErr := stringFromEntry(entry, "item_owner") - issueRepo, repoErr := stringFromEntry(entry, "item_repo") - issueNumber, numErr := intFromEntry(entry, "issue_number") - if ownerErr != nil || repoErr != nil || numErr != nil { - return 0, fmt.Errorf("each item requires either item_id, or item_owner + item_repo + issue_number") - } - if gqlClient == nil { - return 0, fmt.Errorf("internal error: gqlClient is required to resolve items by issue number") - } - return resolveProjectItemIDByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) -} - -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) - } - switch n := v.(type) { - case float64: - return int(n), nil - case int: - return n, nil - case int64: - return int(n), nil - default: - return 0, fmt.Errorf("%s must be a number", key) - } -} - -// batchErrorFromResolution converts an error from resolution/build helpers into a -// batch-item error. Structured resolution errors preserve their code, hint, and -// candidates so agents can self-correct per-item without failing the batch. -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: "invalid_argument", - Message: err.Error(), - } -} - func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error @@ -1861,82 +1572,6 @@ func validateAndConvertToInt64(value any) (int64, error) { } } -// projectFieldsCache lazily loads every field on a project once, so callers that -// need to resolve multiple field names against the same project pay a single -// GraphQL round trip instead of one per name. -type projectFieldsCache struct { - loaded bool - fields []ResolvedField - byName map[string][]ResolvedField -} - -func newProjectFieldsCache() *projectFieldsCache { - return &projectFieldsCache{} -} - -func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) error { - if c.loaded { - return nil - } - all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) - if err != nil { - return err - } - c.fields = all - c.byName = make(map[string][]ResolvedField, len(all)) - for _, f := range all { - key := strings.ToLower(f.Name) - c.byName[key] = append(c.byName[key], f) - } - c.loaded = true - return nil -} - -// resolveFieldByName is a cached variant of resolveProjectFieldByName: it uses the -// cache's in-memory index instead of querying GraphQL per lookup. -func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName string) (*ResolvedField, error) { - if fieldName == "" { - return nil, fmt.Errorf("field name must not be empty") - } - if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { - return nil, err - } - matches := c.byName[strings.ToLower(fieldName)] - switch len(matches) { - case 0: - candidates := make([]any, 0, len(c.fields)) - for _, f := range c.fields { - candidates = append(candidates, map[string]any{ - "name": f.Name, - "data_type": f.DataType, - }) - } - return nil, ghErrors.NewStructuredResolutionError( - "field_not_found", - fieldName, - fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), - candidates, - ) - case 1: - f := matches[0] - return &f, nil - default: - candidates := make([]any, 0, len(matches)) - for _, f := range matches { - candidates = append(candidates, map[string]any{ - "id": f.ID, - "data_type": f.DataType, - }) - } - return nil, ghErrors.NewStructuredResolutionError( - "field_ambiguous", - fieldName, - "multiple fields share this name; pass updated_field.id to disambiguate", - candidates, - ) - } -} - // buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input) diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go new file mode 100644 index 0000000000..2599c126a1 --- /dev/null +++ b/pkg/github/projects_batch.go @@ -0,0 +1,364 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "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" +) + +// batchItemStatus is the tri-state outcome of one item in update_project_items. +// - succeeded: the item's alias came back populated, confirming the write. +// - failed: a deterministic failure before any mutation was sent (invalid +// input, resolution failure, value-conversion failure, duplicate target). +// - unknown: a mutation touching this item returned a GraphQL error (or was +// never sent after an earlier chunk looked like a systemic failure) and +// the alias did not come back populated. The pinned client drops +// errors[].path, so we can't confirm the write; we don't retry or issue a +// per-item fallback mutation. +type batchItemStatus string + +const ( + batchItemSucceeded batchItemStatus = "succeeded" + batchItemFailed batchItemStatus = "failed" + batchItemUnknown batchItemStatus = "unknown" +) + +// batchItemResult is the per-item outcome returned by update_project_items. +type batchItemResult struct { + Index int `json:"index"` + Status batchItemStatus `json:"status"` + Item *batchItemIdentity `json:"item,omitempty"` + Error *batchItemError `json:"error,omitempty"` + // Ref echoes the identifying input for the item so agents can correlate results + // with the request even when resolution fails. + Ref map[string]any `json:"ref,omitempty"` +} + +// batchItemIdentity is the minimal item identity echoed back for a confirmed +// write: the mutation response's `projectV2Item { id, fullDatabaseId }`, plus +// the numeric item_id the request used (when known), so callers can correlate +// without a follow-up read. +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"` +} + +// resolvedBatchItem is a fully-resolved, validated write, ready to be included +// in an aliased update or clear mutation. +type resolvedBatchItem struct { + index int + nodeID string + fullDatabaseID int64 + fieldNodeID string + value githubv4.ProjectV2FieldValue // unused when this is a clear +} + +// updateProjectItemsBatch handles the update_project_items method. It resolves +// the project, every distinct field, and every item once up front, then writes +// via chunked, aliased updateProjectV2ItemFieldValue / clearProjectV2ItemFieldValue +// GraphQL mutations (pkg/github/projects_batch_mutation.go) instead of one REST +// PATCH per item. +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 + } + + var sharedField map[string]any + if rawShared, hasShared := args["updated_field"]; hasShared { + if m, ok := rawShared.(map[string]any); ok && m != nil { + sharedField = m + } else { + return utils.NewToolResultError("updated_field must be an object"), 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, sharedField) + } + + results := make([]batchItemResult, len(itemsRaw)) + for i, p := range parsed { + if p.err != nil { + results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: p.err} + } + } + + // Resolve the project node ID once; every mutation input needs it. + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectResolveIDFailedError, err)), nil, nil + } + + // Resolve every distinct effective field once. + cache := newProjectFieldsCache() + fieldResolutions := resolveDistinctFields(ctx, gqlClient, cache, owner, ownerType, projectNumber, parsed) + + // Resolve numeric item_id -> node id, deduplicated with bounded concurrency. + 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) + + // Resolve issue-ref items, deduplicated. + issueLookups := resolveIssueRefs(ctx, gqlClient, owner, ownerType, projectNumber, parsed) + + // Build the final resolved plan, rejecting duplicate item+field targets. + var updates, clears []resolvedBatchItem + seenTargets := make(map[string]int) // "nodeID|fieldNodeID" -> first index that claimed it + + for i, p := range parsed { + if p.err != nil { + continue // deterministic parse failure already recorded above + } + + fieldKey, keyErr := fieldSpecKey(p.fieldSpec) + if keyErr != nil { + results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: &batchItemError{Code: "invalid_updated_field", Message: keyErr.Error()}} + continue + } + fr := fieldResolutions[fieldKey] + if fr.err != nil { + results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(fr.err)} + 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 + } + + rawValue, hasValue := p.fieldSpec["value"] + if !hasValue { + results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: &batchItemError{Code: "invalid_updated_field", Message: "updated_field.value is required"}} + continue + } + + targetKey := nodeID + "|" + fr.field.NodeID + if firstIndex, dup := seenTargets[targetKey]; dup { + results[i] = batchItemResult{ + Index: i, Status: batchItemFailed, Ref: p.ref, + Error: &batchItemError{ + Code: "duplicate_target", + Message: fmt.Sprintf("items[%d] targets the same item and field as items[%d]; each item+field pair may only be written once per call", i, firstIndex), + }, + } + continue + } + + if rawValue == nil { + seenTargets[targetKey] = i + clears = append(clears, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID}) + continue + } + + value, convErr := convertProjectFieldValue(fr.field, rawValue) + if convErr != nil { + results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(convErr)} + continue + } + + seenTargets[targetKey] = i + updates = append(updates, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID, value: value}) + } + + // Execute update mutations, then clear mutations, in sequential chunks. + // Clears never run if the update phase aborted: we do not know whether an + // ambiguous failure landed, so we do not risk further writes this call. + aborted := executeBatchPartition(ctx, gqlClient, batchMutationUpdate, projectID, updates, results, parsed) + if !aborted { + executeBatchPartition(ctx, gqlClient, batchMutationClear, projectID, clears, results, parsed) + } else { + markChunkUnknown(clears, results, parsed, fmt.Errorf("batch aborted before this item's mutation was sent, due to an earlier ambiguous transport failure or context cancellation")) + } + + 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 +} + +// resolveItemReference maps a parsed item's reference to its project item node +// ID and (when known) numeric full database ID, using the pre-computed lookup +// tables for the item_id and issue-ref cases. +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") + } +} + +// executeBatchPartition executes a single partition (all updates, or all +// clears) in sequential chunks of at most batchMutationWireChunkSize. It +// writes outcomes directly into results (keyed by original index) and returns +// true if execution was aborted before every item in the partition was +// attempted, because an earlier chunk's failure looked systemic (transport +// failure or context cancellation) rather than a per-mutation GraphQL error. +func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, projectID githubv4.ID, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) bool { + for start := 0; start < len(items); start += batchMutationWireChunkSize { + if ctx.Err() != nil { + markChunkUnknown(items[start:], results, parsed, ctx.Err()) + return true + } + + end := min(start+batchMutationWireChunkSize, len(items)) + chunk := items[start:end] + + inputs := make([]githubv4.Input, len(chunk)) + for i, item := range chunk { + if kind == batchMutationClear { + inputs[i] = githubv4.ClearProjectV2ItemFieldValueInput{ + ProjectID: projectID, + ItemID: githubv4.ID(item.nodeID), + FieldID: githubv4.ID(item.fieldNodeID), + } + } else { + inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{ + ProjectID: projectID, + ItemID: githubv4.ID(item.nodeID), + FieldID: githubv4.ID(item.fieldNodeID), + Value: item.value, + } + } + } + + outcomes, mutateErr := executeAliasedMutation(ctx, gqlClient, kind, inputs) + + anyPopulated := false + for i, oc := range outcomes { + if oc.Populated { + anyPopulated = true + results[chunk[i].index] = batchItemResult{ + Index: chunk[i].index, + Status: batchItemSucceeded, + Ref: parsed[chunk[i].index].ref, + Item: &batchItemIdentity{ + NodeID: oc.NodeID, + FullDatabaseID: oc.FullDatabaseID, + ItemID: chunk[i].fullDatabaseID, + }, + } + } + } + + if mutateErr != nil { + if !anyPopulated { + // Nothing in this chunk is confirmed. This looks like a + // systemic (transport-level) failure rather than a per-item + // GraphQL error, so we stop rather than risk sending further + // writes whose outcome we also could not confirm. Mark this + // chunk AND every remaining item in this partition (not yet + // attempted) unknown. + markChunkUnknown(items[start:], results, parsed, mutateErr) + return true + } + markUnpopulatedUnknown(chunk, outcomes, results, parsed, mutateErr) + continue + } + + // No error: every alias should have come back populated; be defensive + // in case the server returns a well-formed response missing some aliases. + markUnpopulatedUnknown(chunk, outcomes, results, parsed, fmt.Errorf("mutation response did not include this item")) + } + return false +} + +func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasOutcome, results []batchItemResult, parsed []parsedBatchItem, err error) { + for i, oc := range outcomes { + if oc.Populated { + continue + } + results[chunk[i].index] = batchItemResult{ + Index: chunk[i].index, + Status: batchItemUnknown, + Ref: parsed[chunk[i].index].ref, + Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()}, + } + } +} + +func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem, err error) { + for _, item := range chunk { + if results[item.index].Status == batchItemSucceeded { + continue + } + results[item.index] = batchItemResult{ + Index: item.index, + Status: batchItemUnknown, + Ref: parsed[item.index].ref, + Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()}, + } + } +} diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_e2e_test.go new file mode 100644 index 0000000000..4b5b443bc1 --- /dev/null +++ b/pkg/github/projects_batch_e2e_test.go @@ -0,0 +1,646 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync/atomic" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fieldNode is a generic project field response node for use in mock data, +// covering data types beyond SINGLE_SELECT (statusFieldNode in +// projects_resolver_test.go is fixed to SINGLE_SELECT). See the comment on +// listAllProjectFields's inline-fragment decoding: the underlying jsonutil +// decoder populates id/databaseId/name/dataType identically across all three +// ProjectV2*Field fragments for a flat node object, so a single flat map +// (with "options" only where relevant) is sufficient regardless of dataType. +func fieldNode(nodeID string, databaseID int, name, dataType string) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": dataType, + } +} + +// projectIDMatcher returns the githubv4mock matcher for the org project-node-ID +// resolution query issued once per update_project_items call. +func projectIDMatcher(owner string, projectNumber int, projectNodeID string) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": projectNodeID}, + }, + }), + ) +} + +func Test_ProjectsWrite_UpdateProjectItems_NodeIDBypassesRESTLookup(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "updateProjectV2ItemFieldValue") + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, + }) + }, + } + gqlClient := newTestGQLClient(transport) + + // No REST handlers registered at all: if the implementation ever fell back + // to a REST lookup for a node_id-addressed item, this would 404. + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + "items": []any{ + map[string]any{"node_id": "PVTI_item1"}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(0), response["failed"]) + assert.Equal(t, float64(0), response["unknown"]) +} + +func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + fieldNode("PVTF_estimate", 102, "Estimate", "NUMBER"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + ids := map[int]struct{ NodeID, FullDatabaseID string }{} + // Two items in this chunk (item0, item1), sharing the same + // underlying project item. + ids[0] = struct{ NodeID, FullDatabaseID string }{NodeID: "PVTI_item1001", FullDatabaseID: "1001"} + ids[1] = struct{ NodeID, FullDatabaseID string }{NodeID: "PVTI_item1001", FullDatabaseID: "1001"} + return http.StatusOK, mutationDataResponse(t, ids) + }, + } + gqlClient := newTestGQLClient(transport) + + var restCalls int32 + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&restCalls, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`)) + }, + })) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "items": []any{ + map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"name": "Notes", "value": "hello"}}, + map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"name": "Estimate", "value": float64(3)}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(2), response["succeeded"]) + assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls), "the same numeric item_id must only be resolved once") +} + +func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("github"), + "issueRepo": githubv4.String("planning-tracking"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": "PVTI_item2002", + "fullDatabaseId": "2002", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"}, + }) + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + "items": []any{ + map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + results := response["results"].([]any) + item := results[0].(map[string]any)["item"].(map[string]any) + assert.Equal(t, "2002", item["full_database_id"]) +} + +func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + // Only one alias should ever be sent: the duplicate must never reach + // a mutation request. + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, + }) + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + "items": []any{ + map[string]any{"node_id": "PVTI_item1"}, + map[string]any{"node_id": "PVTI_item1"}, // same item + same field -> duplicate + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) + + results := response["results"].([]any) + second := results[1].(map[string]any) + assert.Equal(t, "failed", second["status"]) + assert.Equal(t, "duplicate_target", second["error"].(map[string]any)["code"]) + assert.Equal(t, 1, len(transport.mutationCalls)) +} + +func Test_ProjectsWrite_UpdateProjectItems_TwentyWritesIsOneMutationRequest(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + transport := chunkSizeTestRun(t, toolDef, 20) + assert.Len(t, transport.mutationCalls, 1) +} + +func Test_ProjectsWrite_UpdateProjectItems_TwentyOneWritesIsTwoMutationRequests(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + transport := chunkSizeTestRun(t, toolDef, 21) + assert.Len(t, transport.mutationCalls, 2) +} + +// chunkSizeTestRun runs an update_project_items call with itemCount node_id +// items (all TEXT field updates), returning the mutationAwareTransport so the +// caller can assert on how many aliased-mutation HTTP requests were made. +func chunkSizeTestRun(t *testing.T, toolDef inventory.ServerTool, itemCount int) *mutationAwareTransport { + t.Helper() + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + // input (index 0) plus inputN for each additional alias in this chunk. + chunkSize := len(req.Variables) + ids := make(map[int]struct{ NodeID, FullDatabaseID string }, chunkSize) + for i := range chunkSize { + ids[i] = struct{ NodeID, FullDatabaseID string }{ + NodeID: "PVTI_chunk", + FullDatabaseID: "1", + } + } + return http.StatusOK, mutationDataResponse(t, ids) + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + items := make([]any, itemCount) + for i := range itemCount { + items[i] = map[string]any{"node_id": fmt.Sprintf("PVTI_item%d", i)} + } + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + "items": items, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(itemCount), response["succeeded"]) + + return transport +} + +func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(callIndex int, req capturedGraphQLRequest) (int, string) { + switch callIndex { + case 0: + // Update partition: original items 0 and 2 (item1 is a clear). + assert.Contains(t, req.Query, "updateProjectV2ItemFieldValue") + assert.NotContains(t, req.Query, "clearProjectV2ItemFieldValue") + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, + 1: {NodeID: "PVTI_item2", FullDatabaseID: "1002"}, + }) + case 1: + // Clear partition: original item 1. + assert.Contains(t, req.Query, "clearProjectV2ItemFieldValue") + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, + }) + default: + t.Fatalf("unexpected mutation call #%d", callIndex) + return http.StatusInternalServerError, "" + } + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes"}, + "items": []any{ + map[string]any{"node_id": "PVTI_item0", "updated_field": map[string]any{"name": "Notes", "value": "a"}}, + map[string]any{"node_id": "PVTI_item1", "updated_field": map[string]any{"name": "Notes", "value": nil}}, + map[string]any{"node_id": "PVTI_item2", "updated_field": map[string]any{"name": "Notes", "value": "b"}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(3), response["succeeded"]) + + results := response["results"].([]any) + require.Len(t, results, 3) + for i, r := range results { + entry := r.(map[string]any) + assert.Equal(t, float64(i), entry["index"], "results must stay ordered by original index regardless of chunk grouping") + assert.Equal(t, "succeeded", entry["status"]) + } + assert.Equal(t, "1000", results[0].(map[string]any)["item"].(map[string]any)["full_database_id"]) + assert.Equal(t, "1001", results[1].(map[string]any)["item"].(map[string]any)["full_database_id"]) + assert.Equal(t, "1002", results[2].(map[string]any)["item"].(map[string]any)["full_database_id"]) +} + +func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(callIndex int, _ capturedGraphQLRequest) (int, string) { + if callIndex == 0 { + // Systemic transport-level failure: no data at all. + return http.StatusInternalServerError, `{"message":"internal server error"}` + } + t.Fatalf("chunk #%d must not execute after an ambiguous chunk-level failure", callIndex) + return http.StatusInternalServerError, "" + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + // 25 update items (2 chunks of 20 + 5) plus 1 clear item; only the first + // update chunk should ever be sent. + items := make([]any, 0, 26) + for i := range 25 { + items = append(items, map[string]any{ + "node_id": fmt.Sprintf("PVTI_item%d", i), + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + }) + } + items = append(items, map[string]any{ + "node_id": "PVTI_clear", + "updated_field": map[string]any{"name": "Notes", "value": nil}, + }) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "items": items, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + // No item succeeded (all unknown after the abort), so IsError is set per + // the "no item succeeded" rule, even though nothing was deterministically + // rejected; the structured result (with unknown statuses) is still available. + assert.True(t, result.IsError) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(0), response["succeeded"]) + assert.Equal(t, float64(26), response["unknown"]) + assert.Len(t, transport.mutationCalls, 1, "only the first (failing) chunk should have been sent") + + results := response["results"].([]any) + for _, r := range results { + assert.Equal(t, "unknown", r.(map[string]any)["status"]) + } +} + +func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + )) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "items": []any{ + map[string]any{}, // no reference at all: deterministic failure + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.True(t, result.IsError, "IsError must be set when no item in the batch succeeds") + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(0), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) +} + +func Test_ProjectsWrite_UpdateProjectItems_MixedOutcomeKeepsIsErrorFalse(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, + }) + }, + } + gqlClient := newTestGQLClient(transport) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + "items": []any{ + map[string]any{"node_id": "PVTI_item0"}, + map[string]any{}, // deterministic failure + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError, "mixed outcomes must keep IsError false so the structured result stays available") + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) +} + +// Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring verifies the +// batch mutation path works unchanged when gqlClient was constructed via +// githubv4.NewEnterpriseClient (GHES), not just githubv4.NewClient: the +// reflection-based mutation logic never assumes a specific endpoint and only +// ever uses the injected client. +func Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, + }) + }, + } + gqlClient := githubv4.NewEnterpriseClient("https://ghe.example.com/graphql", &http.Client{Transport: transport}) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + "items": []any{ + map[string]any{"node_id": "PVTI_item0"}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) +} diff --git a/pkg/github/projects_batch_resolve.go b/pkg/github/projects_batch_resolve.go new file mode 100644 index 0000000000..43950b6d84 --- /dev/null +++ b/pkg/github/projects_batch_resolve.go @@ -0,0 +1,577 @@ +package github + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/google/go-github/v89/github" + "github.com/shurcooL/githubv4" +) + +// batchItemLookupConcurrency bounds concurrent REST GETs used to resolve +// numeric item_id values to project item node IDs. +const batchItemLookupConcurrency = 5 + +// projectFieldsCache lazily loads every field on a project once, so callers +// that need to resolve multiple field names/IDs against the same project pay +// a single GraphQL round trip instead of one per lookup. +type projectFieldsCache struct { + loaded bool + fields []ResolvedField + byName map[string][]ResolvedField + byID map[string]ResolvedField +} + +func newProjectFieldsCache() *projectFieldsCache { + return &projectFieldsCache{} +} + +func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) error { + if c.loaded { + return nil + } + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return err + } + c.fields = all + c.byName = make(map[string][]ResolvedField, len(all)) + c.byID = make(map[string]ResolvedField, len(all)) + for _, f := range all { + key := strings.ToLower(f.Name) + c.byName[key] = append(c.byName[key], f) + c.byID[f.ID] = f + } + c.loaded = true + return nil +} + +// resolveFieldByName is a cached variant of resolveProjectFieldByName: it uses the +// cache's in-memory index instead of querying GraphQL per lookup. +func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName string) (*ResolvedField, error) { + if fieldName == "" { + return nil, fmt.Errorf("field name must not be empty") + } + if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { + return nil, err + } + matches := c.byName[strings.ToLower(fieldName)] + switch len(matches) { + case 0: + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + fieldName, + fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), + c.fieldCandidates(), + ) + case 1: + f := matches[0] + return &f, nil + default: + candidates := make([]any, 0, len(matches)) + for _, f := range matches { + candidates = append(candidates, map[string]any{ + "id": f.ID, + "data_type": f.DataType, + }) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_ambiguous", + fieldName, + "multiple fields share this name; pass updated_field.id to disambiguate", + candidates, + ) + } +} + +// resolveFieldByID is a cached variant of resolveProjectFieldByName's numeric +// counterpart: it uses the cache's in-memory index instead of a fresh +// GraphQL round trip per lookup. +func (c *projectFieldsCache) resolveFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) { + if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { + return nil, err + } + idStr := fmt.Sprintf("%d", fieldID) + if f, ok := c.byID[idStr]; ok { + field := f + return &field, nil + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + idStr, + fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", idStr, owner, projectNumber), + c.fieldCandidates(), + ) +} + +func (c *projectFieldsCache) fieldCandidates() []any { + candidates := make([]any, 0, len(c.fields)) + for _, f := range c.fields { + candidates = append(candidates, map[string]any{ + "id": f.ID, + "name": f.Name, + "data_type": f.DataType, + }) + } + return candidates +} + +// batchItemRefKind identifies which of the mutually exclusive item-reference +// shapes a batch entry used. +type batchItemRefKind int + +const ( + batchRefNodeID batchItemRefKind = iota + batchRefItemID + batchRefIssue +) + +// parsedBatchItem is the result of validating one items[] entry, before any +// network resolution has happened. err, if set, is a deterministic failure +// (bad shape, missing/ambiguous reference form, malformed updated_field). +type parsedBatchItem struct { + index int + ref map[string]any + refKind batchItemRefKind + + nodeID string // set when refKind == batchRefNodeID + itemID int64 // set when refKind == batchRefItemID + + issueOwner string // set when refKind == batchRefIssue + issueRepo string + issueNumber int + + fieldSpec map[string]any // the effective updated_field for this item + err *batchItemError +} + +// parseBatchItemEntry validates one raw items[] entry and determines its +// reference kind. It performs no network I/O. +func parseBatchItemEntry(index int, raw any, sharedField map[string]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) + + effectiveField, fieldErr := effectiveUpdatedField(entry, sharedField) + if fieldErr != nil { + p.err = &batchItemError{Code: "invalid_updated_field", Message: fieldErr.Error()} + return p + } + p.fieldSpec = effectiveField + + if refErr := p.parseItemRef(entry); refErr != nil { + p.err = &batchItemError{Code: "invalid_item_ref", Message: refErr.Error()} + } + return p +} + +// parseItemRef reads the item-reference fields from entry, requiring exactly +// one of: node_id, item_id, or (item_owner + item_repo + issue_number). +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 := validateAndConvertToInt64(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") + if ownerErr != nil || repoErr != nil || numErr != nil { + return fmt.Errorf("item_owner, item_repo, and issue_number must all be provided together") + } + p.refKind = batchRefIssue + p.issueOwner = issueOwner + p.issueRepo = issueRepo + p.issueNumber = issueNumber + } + return nil +} + +// effectiveUpdatedField picks the per-item updated_field when set, otherwise falls +// back to the shared top-level value. Returns an error if neither is present or the +// per-item override is malformed. +func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) { + if raw, ok := entry["updated_field"]; ok { + m, isMap := raw.(map[string]any) + if !isMap || m == nil { + return nil, fmt.Errorf("updated_field must be an object") + } + return m, nil + } + if shared != nil { + return shared, nil + } + return nil, fmt.Errorf("updated_field is required either at the top level or on each item") +} + +// itemRefEcho returns a copy of the identifying fields of an item entry, used to +// help agents correlate per-item results with their inputs. +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) + } + switch n := v.(type) { + case float64: + return int(n), nil + case int: + return n, nil + case int64: + return int(n), nil + default: + return 0, fmt.Errorf("%s must be a number", key) + } +} + +// fieldSpecKey returns a canonical dedup key for an updated_field spec, so the +// batch resolves each distinct field at most once regardless of how many +// items reference it. +func fieldSpecKey(spec map[string]any) (string, error) { + idField, hasID := spec["id"] + nameField, hasName := spec["name"] + + switch { + case hasID && hasName: + return "", fmt.Errorf("updated_field must set either id or name, not both") + case !hasID && !hasName: + return "", fmt.Errorf("updated_field requires either id or name") + case hasID: + id, err := validateAndConvertToInt64(idField) + if err != nil { + return "", fmt.Errorf("updated_field.id: %w", err) + } + return fmt.Sprintf("id:%d", id), nil + default: + name, ok := nameField.(string) + if !ok || name == "" { + return "", fmt.Errorf("updated_field.name must be a non-empty string") + } + return "name:" + strings.ToLower(name), nil + } +} + +// fieldResolution is the outcome of resolving one distinct field spec. +type fieldResolution struct { + field *ResolvedField + err error +} + +// resolveDistinctFields resolves every distinct effective updated_field spec +// referenced by items exactly once, using cache to amortise the underlying +// fields(...) GraphQL round trip across the whole batch. +func resolveDistinctFields(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, items []parsedBatchItem) map[string]fieldResolution { + resolved := make(map[string]fieldResolution) + for _, it := range items { + if it.err != nil || it.fieldSpec == nil { + continue + } + key, keyErr := fieldSpecKey(it.fieldSpec) + if keyErr != nil { + // Surfaced again per-item in the caller; nothing to cache here. + continue + } + if _, done := resolved[key]; done { + continue + } + resolved[key] = resolveFieldSpec(ctx, gqlClient, cache, owner, ownerType, projectNumber, it.fieldSpec) + } + return resolved +} + +func resolveFieldSpec(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, spec map[string]any) fieldResolution { + if idField, hasID := spec["id"]; hasID { + id, err := validateAndConvertToInt64(idField) + if err != nil { + return fieldResolution{err: fmt.Errorf("updated_field.id: %w", err)} + } + f, err := cache.resolveFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, id) + return fieldResolution{field: f, err: err} + } + name, _ := spec["name"].(string) + f, err := cache.resolveFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, name) + return fieldResolution{field: f, err: err} +} + +// convertProjectFieldValue converts a raw JSON value into the +// githubv4.ProjectV2FieldValue member appropriate for field's data type. +// Supported types are TEXT, NUMBER, DATE, SINGLE_SELECT, and ITERATION; +// anything else (e.g. ASSIGNEES, LABELS, TITLE) returns a deterministic error +// directing the caller to update_project_item, which delegates conversion to +// the REST API. +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) { + switch v := raw.(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } +} + +// itemLookupResult is the outcome of resolving one item reference (by numeric +// item_id or by issue ref) to a project item's GraphQL node ID. +type itemLookupResult struct { + nodeID string + fullDatabaseID int64 + err error +} + +// resolveItemNodeIDsByNumericID resolves a set of numeric item_id values to +// their project item node IDs via REST GETs, deduplicating repeated IDs and +// bounding concurrency to batchItemLookupConcurrency. Each lookup's error (if +// any) is independent: one failing ID does not cancel the others, and ctx +// cancellation is still respected for in-flight and not-yet-started lookups. +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 +} + +// issueRefKey dedupes issue-ref lookups shared by more than one batch item. +type issueRefKey struct { + owner string + repo string + number int +} + +// resolveIssueRefs resolves every distinct (owner, repo, issue_number) triplet +// referenced by items to a project item node ID + full database ID, reusing +// the #2914 paginated resolver. Distinct refs are resolved once each. +func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, items []parsedBatchItem) map[issueRefKey]itemLookupResult { + out := make(map[issueRefKey]itemLookupResult) + for _, it := range items { + if it.err != nil || it.refKind != batchRefIssue { + continue + } + key := issueRefKey{owner: it.issueOwner, repo: it.issueRepo, number: it.issueNumber} + if _, done := out[key]; done { + continue + } + nodeID, itemID, err := resolveProjectItemByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, it.issueOwner, it.issueRepo, it.issueNumber) + out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err} + } + return out +} + +// batchErrorFromResolution converts an error from resolution/build helpers into a +// batch-item error. Structured resolution errors preserve their code, hint, and +// candidates so agents can self-correct per-item without failing the batch. +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: "invalid_argument", + Message: err.Error(), + } +} diff --git a/pkg/github/projects_batch_resolve_test.go b/pkg/github/projects_batch_resolve_test.go new file mode 100644 index 0000000000..041d8a66e3 --- /dev/null +++ b/pkg/github/projects_batch_resolve_test.go @@ -0,0 +1,243 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ParseItemRef_ExactlyOneFormRequired(t *testing.T) { + tests := []struct { + name string + entry map[string]any + wantErr string + }{ + { + name: "none provided", + entry: map[string]any{}, + wantErr: "exactly one of", + }, + { + name: "node_id and item_id both provided", + entry: map[string]any{"node_id": "PVTI_x", "item_id": float64(1)}, + wantErr: "not more than one", + }, + { + name: "item_id and issue ref both provided", + entry: map[string]any{"item_id": float64(1), "item_owner": "o", "item_repo": "r", "issue_number": float64(1)}, + wantErr: "not more than one", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(tt.entry) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_ParseItemRef_NodeIDBypassesLookup(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"node_id": "PVTI_abc123"}) + require.NoError(t, err) + assert.Equal(t, batchRefNodeID, p.refKind) + assert.Equal(t, "PVTI_abc123", p.nodeID) +} + +func Test_ParseItemRef_ItemID(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_id": float64(42)}) + require.NoError(t, err) + assert.Equal(t, batchRefItemID, p.refKind) + assert.Equal(t, int64(42), p.itemID) +} + +func Test_ParseItemRef_IssueRef(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)}) + require.NoError(t, err) + assert.Equal(t, batchRefIssue, p.refKind) + assert.Equal(t, "github", p.issueOwner) + assert.Equal(t, "planning-tracking", p.issueRepo) + assert.Equal(t, 123, p.issueNumber) +} + +func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_owner": "github"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must all be provided together") +} + +func Test_ParseBatchItemEntry_InvalidShape(t *testing.T) { + p := parseBatchItemEntry(0, "not-an-object", nil) + require.NotNil(t, p.err) + assert.Equal(t, "invalid_item", p.err.Code) +} + +func Test_ParseBatchItemEntry_UsesSharedFieldWhenNoPerItemOverride(t *testing.T) { + shared := map[string]any{"id": float64(101), "value": "In Progress"} + p := parseBatchItemEntry(0, map[string]any{"item_id": float64(1)}, shared) + require.Nil(t, p.err) + assert.Equal(t, shared, p.fieldSpec) +} + +func Test_ParseBatchItemEntry_PerItemOverrideWinsOverShared(t *testing.T) { + shared := map[string]any{"id": float64(101), "value": "In Progress"} + override := map[string]any{"id": float64(202), "value": "Done"} + p := parseBatchItemEntry(0, map[string]any{"item_id": float64(1), "updated_field": override}, shared) + require.Nil(t, p.err) + assert.Equal(t, override, p.fieldSpec) +} + +func Test_FieldSpecKey(t *testing.T) { + tests := []struct { + name string + spec map[string]any + want string + wantErr bool + }{ + {name: "by id", spec: map[string]any{"id": float64(101), "value": "x"}, want: "id:101"}, + {name: "by name", spec: map[string]any{"name": "Status", "value": "x"}, want: "name:status"}, + {name: "by name case-insensitive dedup", spec: map[string]any{"name": "STATUS", "value": "x"}, want: "name:status"}, + {name: "both id and name", spec: map[string]any{"id": float64(1), "name": "Status", "value": "x"}, wantErr: true}, + {name: "neither id nor name", spec: map[string]any{"value": "x"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := fieldSpecKey(tt.spec) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_ConvertProjectFieldValue_Text(t *testing.T) { + field := &ResolvedField{Name: "Notes", DataType: "TEXT"} + v, err := convertProjectFieldValue(field, "hello") + require.NoError(t, err) + require.NotNil(t, v.Text) + assert.Equal(t, "hello", string(*v.Text)) +} + +func Test_ConvertProjectFieldValue_Text_WrongType(t *testing.T) { + field := &ResolvedField{Name: "Notes", DataType: "TEXT"} + _, err := convertProjectFieldValue(field, float64(1)) + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_Number(t *testing.T) { + field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} + v, err := convertProjectFieldValue(field, float64(8)) + require.NoError(t, err) + require.NotNil(t, v.Number) + assert.InDelta(t, 8.0, float64(*v.Number), 0.0001) +} + +func Test_ConvertProjectFieldValue_Date(t *testing.T) { + field := &ResolvedField{Name: "Due", DataType: "DATE"} + v, err := convertProjectFieldValue(field, "2024-01-15") + require.NoError(t, err) + require.NotNil(t, v.Date) + assert.Equal(t, 2024, v.Date.Year()) + assert.Equal(t, 1, int(v.Date.Month())) + assert.Equal(t, 15, v.Date.Day()) +} + +func Test_ConvertProjectFieldValue_Date_BadFormat(t *testing.T) { + field := &ResolvedField{Name: "Due", DataType: "DATE"} + _, err := convertProjectFieldValue(field, "01/15/2024") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_SingleSelect_ByName(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + v, err := convertProjectFieldValue(field, "In Progress") + require.NoError(t, err) + require.NotNil(t, v.SingleSelectOptionID) + assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) +} + +func Test_ConvertProjectFieldValue_SingleSelect_ByOptionID(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + v, err := convertProjectFieldValue(field, "OPT_1") + require.NoError(t, err) + require.NotNil(t, v.SingleSelectOptionID) + assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) +} + +func Test_ConvertProjectFieldValue_SingleSelect_Unknown(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + _, err := convertProjectFieldValue(field, "Nonexistent") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_Iteration(t *testing.T) { + field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} + v, err := convertProjectFieldValue(field, "abc123==") + require.NoError(t, err) + require.NotNil(t, v.IterationID) + assert.Equal(t, "abc123==", string(*v.IterationID)) +} + +func Test_ConvertProjectFieldValue_Iteration_EmptyIsError(t *testing.T) { + field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} + _, err := convertProjectFieldValue(field, "") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_UnsupportedDataType(t *testing.T) { + field := &ResolvedField{Name: "Assignees", DataType: "ASSIGNEES"} + _, err := convertProjectFieldValue(field, "someone") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported data type") + assert.Contains(t, err.Error(), "update_project_item") +} + +func Test_ProjectFieldsCache_ResolveFieldByID_And_ByName(t *testing.T) { + cache := &projectFieldsCache{ + loaded: true, + fields: []ResolvedField{ + {ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}, + {ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}, + }, + byName: map[string][]ResolvedField{ + "status": {{ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}}, + "estimate": {{ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}}, + }, + byID: map[string]ResolvedField{ + "101": {ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}, + "202": {ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}, + }, + } + + byID, err := cache.resolveFieldByID(t.Context(), nil, "octo-org", "org", 1, 101) + require.NoError(t, err) + assert.Equal(t, "PVTF_status", byID.NodeID) + + byName, err := cache.resolveFieldByName(t.Context(), nil, "octo-org", "org", 1, "estimate") + require.NoError(t, err) + assert.Equal(t, "PVTF_estimate", byName.NodeID) + + _, err = cache.resolveFieldByID(t.Context(), nil, "octo-org", "org", 1, 999) + require.Error(t, err) +} diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 8fa894640e..b08e00cac6 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -803,206 +803,3 @@ func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testin assert.Equal(t, "field_not_found", msg["error"]) assert.Equal(t, "Doesnt Exist", msg["name"]) } - -// Test_ProjectsWrite_UpdateProjectItems_Batch_ByNameAndIssueRef exercises the -// batch method's happy-path resolvers: one item is addressed by numeric item_id, -// another by (item_owner, item_repo, issue_number). Field-name resolution runs -// once for the entire batch. -func Test_ProjectsWrite_UpdateProjectItems_Batch_ByNameAndIssueRef(t *testing.T) { - toolDef := ProjectsWrite(translations.NullTranslationHelper) - - updatedItem := verbosePullRequestProjectItemFixture() - mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), - }) - restClient := mustNewGHClient(t, mockedREST) - - mockedGQL := githubv4mock.NewMockedHTTPClient( - // Project node id resolution (used to filter the issue's project items). - githubv4mock.NewQueryMatcher( - struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - }{}, - map[string]any{ - "owner": githubv4.String("octo-org"), - "projectNumber": githubv4.Int(1), - }, - githubv4mock.DataResponse(map[string]any{ - "organization": map[string]any{ - "projectV2": map[string]any{"id": "PVT_project1"}, - }, - }), - ), - // Issue -> projectItems traversal for the issue-ref item. - githubv4mock.NewQueryMatcher( - resolveItemByIssueQuery{}, - map[string]any{ - "issueOwner": githubv4.String("github"), - "issueRepo": githubv4.String("planning-tracking"), - "issueNumber": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "projectItems": map[string]any{ - "nodes": []any{ - map[string]any{ - "fullDatabaseId": "2002", - "project": map[string]any{"id": "PVT_project1"}, - }, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "", "endCursor": "", - }, - }, - }, - }, - }), - ), - // One-shot field-name resolution shared across the batch. - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ - {"id": "OPT_in_progress", "name": "In Progress"}, - }), - })), - ), - ) - gqlClient := githubv4.NewClient(mockedGQL) - - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{ - "name": "Status", - "value": "In Progress", - }, - "items": []any{ - map[string]any{"item_id": float64(1001)}, - map[string]any{ - "item_owner": "github", - "item_repo": "planning-tracking", - "issue_number": float64(123), - }, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(2), response["succeeded"]) - assert.Equal(t, float64(0), response["failed"]) -} - -// Test_ProjectsWrite_UpdateProjectItems_Batch_PartialFailure_ItemNotInProject -// verifies that when one item's issue is not on the project, its per-item error -// is a structured item_not_in_project code and does not abort the batch. -func Test_ProjectsWrite_UpdateProjectItems_Batch_PartialFailure_ItemNotInProject(t *testing.T) { - toolDef := ProjectsWrite(translations.NullTranslationHelper) - - updatedItem := verbosePullRequestProjectItemFixture() - mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), - }) - restClient := mustNewGHClient(t, mockedREST) - - mockedGQL := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - }{}, - map[string]any{ - "owner": githubv4.String("octo-org"), - "projectNumber": githubv4.Int(1), - }, - githubv4mock.DataResponse(map[string]any{ - "organization": map[string]any{ - "projectV2": map[string]any{"id": "PVT_project1"}, - }, - }), - ), - // Issue exists but its projectItems do NOT include our project. - githubv4mock.NewQueryMatcher( - resolveItemByIssueQuery{}, - map[string]any{ - "issueOwner": githubv4.String("github"), - "issueRepo": githubv4.String("planning-tracking"), - "issueNumber": githubv4.Int(999), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "projectItems": map[string]any{ - "nodes": []any{}, - "pageInfo": map[string]any{ - "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "", "endCursor": "", - }, - }, - }, - }, - }), - ), - ) - gqlClient := githubv4.NewClient(mockedGQL) - - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{ - "id": float64(101), - "value": "In Progress", - }, - "items": []any{ - map[string]any{"item_id": float64(1001)}, - map[string]any{ - "item_owner": "github", - "item_repo": "planning-tracking", - "issue_number": float64(999), - }, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(1), response["succeeded"]) - assert.Equal(t, float64(1), response["failed"]) - - results := response["results"].([]any) - require.Len(t, results, 2) - failed := results[1].(map[string]any) - assert.Equal(t, false, failed["ok"]) - errObj := failed["error"].(map[string]any) - assert.Equal(t, "item_not_in_project", errObj["code"]) - // The ref should echo the identifying issue-ref inputs so agents can correlate. - ref := failed["ref"].(map[string]any) - assert.Equal(t, "github", ref["item_owner"]) - assert.Equal(t, "planning-tracking", ref["item_repo"]) - assert.Equal(t, float64(999), ref["issue_number"]) -} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 09e99fa61e..6908681492 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1225,241 +1225,6 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { }) } -func Test_ProjectsWrite_UpdateProjectItems_Batch(t *testing.T) { - toolDef := ProjectsWrite(translations.NullTranslationHelper) - - t.Run("success with shared updated_field applied to two items", func(t *testing.T) { - updatedItem := verbosePullRequestProjectItemFixture() - mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), - }) - restClient := mustNewGHClient(t, mockedREST) - // No GraphQL calls expected: both entries use item_id + updated_field.id, so - // no name resolution or issue-number lookup is needed. - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{ - "id": float64(101), - "value": "In Progress", - }, - "items": []any{ - map[string]any{"item_id": float64(1001)}, - map[string]any{"item_id": float64(1002)}, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(2), response["total"]) - assert.Equal(t, float64(2), response["succeeded"]) - assert.Equal(t, float64(0), response["failed"]) - results, ok := response["results"].([]any) - require.True(t, ok) - require.Len(t, results, 2) - for _, r := range results { - entry := r.(map[string]any) - assert.Equal(t, true, entry["ok"]) - assert.Nil(t, entry["error"]) - assert.NotNil(t, entry["item"]) - } - }) - - t.Run("resolves field name once for the whole batch", func(t *testing.T) { - updatedItem := verbosePullRequestProjectItemFixture() - - // Count fields(...) calls to confirm we only resolve once for the batch. - var fieldCallCount int32 - fieldsHandler := &countingHTTPHandler{count: &fieldCallCount} - fieldsHandler.matchers = []githubv4mock.Matcher{ - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ - {"id": "OPT_in_progress", "name": "In Progress"}, - }), - })), - ), - } - gqlClient := githubv4.NewClient(&http.Client{Transport: fieldsHandler}) - - mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), - }) - restClient := mustNewGHClient(t, mockedREST) - - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{ - "name": "Status", - "value": "In Progress", - }, - "items": []any{ - map[string]any{"item_id": float64(1001)}, - map[string]any{"item_id": float64(1002)}, - map[string]any{"item_id": float64(1003)}, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - // Exactly one field-resolution round trip regardless of batch size. - assert.Equal(t, int32(1), fieldCallCount) - }) - - t.Run("partial failure: one item resolves, one has an invalid updated_field", func(t *testing.T) { - updatedItem := verbosePullRequestProjectItemFixture() - mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), - }) - restClient := mustNewGHClient(t, mockedREST) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "items": []any{ - map[string]any{ - "item_id": float64(1001), - "updated_field": map[string]any{ - "id": float64(101), - "value": "In Progress", - }, - }, - // Missing both id and name on updated_field: should surface as a per-item error. - map[string]any{ - "item_id": float64(1002), - "updated_field": map[string]any{ - "value": "In Progress", - }, - }, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(2), response["total"]) - assert.Equal(t, float64(1), response["succeeded"]) - assert.Equal(t, float64(1), response["failed"]) - - results := response["results"].([]any) - require.Len(t, results, 2) - first := results[0].(map[string]any) - second := results[1].(map[string]any) - assert.Equal(t, true, first["ok"]) - assert.Equal(t, false, second["ok"]) - errObj := second["error"].(map[string]any) - assert.Equal(t, "invalid_argument", errObj["code"]) - assert.Contains(t, errObj["message"], "either id or name") - }) - - t.Run("missing items array", func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{"id": float64(101), "value": "Todo"}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: items") - }) - - t.Run("empty items array", func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{"id": float64(101), "value": "Todo"}, - "items": []any{}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "at least one entry") - }) - - t.Run("item with no updated_field and no shared updated_field is a per-item error", func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) - deps := BaseDeps{Client: restClient, GQLClient: gqlClient} - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "update_project_items", - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "items": []any{ - map[string]any{"item_id": float64(1001)}, - }, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(0), response["succeeded"]) - assert.Equal(t, float64(1), response["failed"]) - results := response["results"].([]any) - errObj := results[0].(map[string]any)["error"].(map[string]any) - assert.Equal(t, "invalid_updated_field", errObj["code"]) - }) -} - -// countingHTTPHandler wraps a set of githubv4mock matchers with a request counter. -// It behaves like githubv4mock.NewMockedHTTPClient's transport but exposes a hit -// count so tests can assert on the number of round trips. -type countingHTTPHandler struct { - matchers []githubv4mock.Matcher - count *int32 -} - -func (c *countingHTTPHandler) RoundTrip(req *http.Request) (*http.Response, error) { - client := githubv4mock.NewMockedHTTPClient(c.matchers...) - resp, err := client.Transport.RoundTrip(req) - if resp != nil && resp.StatusCode == http.StatusOK { - *c.count++ - } - return resp, err -} - func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) From 57c797b198d278305df8667427727cc8eefdafbc Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Mon, 20 Jul 2026 19:10:30 -0400 Subject: [PATCH 03/11] Harden batch project write resolution and failures Distinguish GraphQL response errors from ambiguous transport or missing-data responses, validate numeric references before narrowing them, reuse the resolved project ID for issue lookups, and cache field-load failures. Add focused regression coverage and align the tool schema with the supported issue-reference contract.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 12 +-- pkg/github/projects.go | 12 +-- pkg/github/projects_batch.go | 67 ++++++------- pkg/github/projects_batch_e2e_test.go | 21 +++-- pkg/github/projects_batch_resolve.go | 90 ++++++++++++------ pkg/github/projects_batch_resolve_test.go | 99 ++++++++++++++++++++ 7 files changed, 219 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 0f07bec685..3414416805 100644 --- a/README.md +++ b/README.md @@ -1131,7 +1131,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'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'. (object, optional) + - `updated_field`: Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_items', provide a shared shape at the top level or override it per 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) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index bdbe757f8c..cb615fcffa 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -46,19 +46,19 @@ "additionalProperties": false, "properties": { "issue_number": { - "description": "Issue number (or pull request number) used to resolve the project item together with item_owner and item_repo.", - "type": "number" + "description": "Issue number used to resolve the project item together with item_owner and item_repo.", + "type": "integer" }, "item_id": { "description": "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", - "type": "number" + "type": "integer" }, "item_owner": { - "description": "Owner of the repository containing the issue or pull request. Combine with item_repo and issue_number to resolve the item.", + "description": "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.", "type": "string" }, "item_repo": { - "description": "Repository containing the issue or pull request. Combine with item_owner and issue_number to resolve the item.", + "description": "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.", "type": "string" }, "node_id": { @@ -162,7 +162,7 @@ "type": "string" }, "updated_field": { - "description": "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'.", + "description": "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_items', provide a shared shape at the top level or override it per 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.", "type": "object" } }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 62b44735ca..3cd65892d5 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -567,7 +567,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "updated_field": { Type: "object", - Description: "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_item' provide either a single-item shape at the top level, or override per-item on 'update_project_items'. 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. For 'update_project_items', a top-level 'updated_field' is applied to every item that does not supply its own 'updated_field'.", + Description: "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_items', provide a shared shape at the top level or override it per 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.", }, "items": { Type: "array", @@ -581,20 +581,20 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Description: "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", }, "item_id": { - Type: "number", + Type: "integer", Description: "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", }, "item_owner": { Type: "string", - Description: "Owner of the repository containing the issue or pull request. Combine with item_repo and issue_number to resolve the item.", + Description: "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.", }, "item_repo": { Type: "string", - Description: "Repository containing the issue or pull request. Combine with item_owner and issue_number to resolve the item.", + Description: "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.", }, "issue_number": { - Type: "number", - Description: "Issue number (or pull request number) used to resolve the project item together with item_owner and item_repo.", + Type: "integer", + Description: "Issue number used to resolve the project item together with item_owner and item_repo.", }, "updated_field": { Type: "object", diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 2599c126a1..71b2d94455 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -15,11 +15,9 @@ import ( // - succeeded: the item's alias came back populated, confirming the write. // - failed: a deterministic failure before any mutation was sent (invalid // input, resolution failure, value-conversion failure, duplicate target). -// - unknown: a mutation touching this item returned a GraphQL error (or was -// never sent after an earlier chunk looked like a systemic failure) and -// the alias did not come back populated. The pinned client drops -// errors[].path, so we can't confirm the write; we don't retry or issue a -// per-item fallback mutation. +// - unknown: a mutation touching this item was not confirmed, or was never +// sent after an earlier systemic failure. The pinned client drops +// errors[].path, so we don't retry or issue a per-item fallback mutation. type batchItemStatus string const ( @@ -106,16 +104,22 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie } 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) + } // Resolve the project node ID once; every mutation input needs it. projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectResolveIDFailedError, err)), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil } // Resolve every distinct effective field once. @@ -132,7 +136,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs) // Resolve issue-ref items, deduplicated. - issueLookups := resolveIssueRefs(ctx, gqlClient, owner, ownerType, projectNumber, parsed) + issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed) // Build the final resolved plan, rejecting duplicate item+field targets. var updates, clears []resolvedBatchItem @@ -143,12 +147,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue // deterministic parse failure already recorded above } - fieldKey, keyErr := fieldSpecKey(p.fieldSpec) - if keyErr != nil { - results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: &batchItemError{Code: "invalid_updated_field", Message: keyErr.Error()}} - continue - } - fr := fieldResolutions[fieldKey] + fr := fieldResolutions[p.fieldKey] if fr.err != nil { results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(fr.err)} continue @@ -160,12 +159,6 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - rawValue, hasValue := p.fieldSpec["value"] - if !hasValue { - results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: &batchItemError{Code: "invalid_updated_field", Message: "updated_field.value is required"}} - continue - } - targetKey := nodeID + "|" + fr.field.NodeID if firstIndex, dup := seenTargets[targetKey]; dup { results[i] = batchItemResult{ @@ -178,13 +171,13 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - if rawValue == nil { + if p.rawValue == nil { seenTargets[targetKey] = i clears = append(clears, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID}) continue } - value, convErr := convertProjectFieldValue(fr.field, rawValue) + value, convErr := convertProjectFieldValue(fr.field, p.rawValue) if convErr != nil { results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(convErr)} continue @@ -204,6 +197,10 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie markChunkUnknown(clears, results, parsed, fmt.Errorf("batch aborted before this item's mutation was sent, due to an earlier ambiguous transport failure or context cancellation")) } + return newUpdateProjectItemsResult(results) +} + +func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult, any, error) { succeeded, failed, unknown := 0, 0, 0 for _, r := range results { switch r.Status { @@ -296,10 +293,10 @@ func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind outcomes, mutateErr := executeAliasedMutation(ctx, gqlClient, kind, inputs) - anyPopulated := false + populated := 0 for i, oc := range outcomes { if oc.Populated { - anyPopulated = true + populated++ results[chunk[i].index] = batchItemResult{ Index: chunk[i].index, Status: batchItemSucceeded, @@ -313,24 +310,20 @@ func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind } } - if mutateErr != nil { - if !anyPopulated { - // Nothing in this chunk is confirmed. This looks like a - // systemic (transport-level) failure rather than a per-item - // GraphQL error, so we stop rather than risk sending further - // writes whose outcome we also could not confirm. Mark this - // chunk AND every remaining item in this partition (not yet - // attempted) unknown. - markChunkUnknown(items[start:], results, parsed, mutateErr) - return true - } + if isGraphQLResponseError(mutateErr) { markUnpopulatedUnknown(chunk, outcomes, results, parsed, mutateErr) continue } - // No error: every alias should have come back populated; be defensive - // in case the server returns a well-formed response missing some aliases. - markUnpopulatedUnknown(chunk, outcomes, results, parsed, fmt.Errorf("mutation response did not include this item")) + if mutateErr != nil { + markChunkUnknown(items[start:], results, parsed, mutateErr) + return true + } + + if populated != len(chunk) { + markChunkUnknown(items[start:], results, parsed, fmt.Errorf("mutation response did not include every item")) + return true + } } return false } diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_e2e_test.go index 4b5b443bc1..632e184c25 100644 --- a/pkg/github/projects_batch_e2e_test.go +++ b/pkg/github/projects_batch_e2e_test.go @@ -125,7 +125,9 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t transport := &mutationAwareTransport{ t: t, queries: queryTransport.Transport, - mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"]) + assert.Equal(t, "PVTF_estimate", req.Variables["input1"].(map[string]any)["fieldId"]) ids := map[int]struct{ NodeID, FullDatabaseID string }{} // Two items in this chunk (item0, item1), sharing the same // underlying project item. @@ -154,7 +156,7 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t "owner_type": "org", "project_number": float64(1), "items": []any{ - map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"name": "Notes", "value": "hello"}}, + map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"id": float64(101), "value": "hello"}}, map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"name": "Estimate", "value": float64(3)}}, }, }) @@ -242,6 +244,7 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test results := response["results"].([]any) item := results[0].(map[string]any)["item"].(map[string]any) assert.Equal(t, "2002", item["full_database_id"]) + assert.Len(t, transport.queryCalls, 3, "the project ID, fields, and issue item should each resolve once") } func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) { @@ -402,6 +405,7 @@ func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t * case 1: // Clear partition: original item 1. assert.Contains(t, req.Query, "clearProjectV2ItemFieldValue") + assert.NotContains(t, req.Variables["input"].(map[string]any), "value") return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, }) @@ -521,9 +525,11 @@ func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t * func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + mocked := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), - )) + ) + countingTransport := &requestCountingTransport{inner: mocked.Transport} + gqlClient := newTestGQLClient(countingTransport) deps := BaseDeps{Client: restClient, GQLClient: gqlClient} handler := toolDef.Handler(deps) @@ -533,7 +539,9 @@ func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) { "owner_type": "org", "project_number": float64(1), "items": []any{ - map[string]any{}, // no reference at all: deterministic failure + map[string]any{}, + map[string]any{"node_id": "PVTI_missing_value", "updated_field": map[string]any{"name": "Notes"}}, + map[string]any{"node_id": "PVTI_bad_field", "updated_field": map[string]any{"id": float64(1), "name": "Notes", "value": "x"}}, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -543,7 +551,8 @@ func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) { var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) assert.Equal(t, float64(0), response["succeeded"]) - assert.Equal(t, float64(1), response["failed"]) + assert.Equal(t, float64(3), response["failed"]) + assert.Zero(t, countingTransport.count, "an all-invalid batch should not perform GraphQL resolution") } func Test_ProjectsWrite_UpdateProjectItems_MixedOutcomeKeepsIsErrorFalse(t *testing.T) { diff --git a/pkg/github/projects_batch_resolve.go b/pkg/github/projects_batch_resolve.go index 43950b6d84..c541e48869 100644 --- a/pkg/github/projects_batch_resolve.go +++ b/pkg/github/projects_batch_resolve.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "sync" "time" @@ -21,10 +22,11 @@ const batchItemLookupConcurrency = 5 // that need to resolve multiple field names/IDs against the same project pay // a single GraphQL round trip instead of one per lookup. type projectFieldsCache struct { - loaded bool - fields []ResolvedField - byName map[string][]ResolvedField - byID map[string]ResolvedField + loaded bool + loadErr error + fields []ResolvedField + byName map[string][]ResolvedField + byID map[string]ResolvedField } func newProjectFieldsCache() *projectFieldsCache { @@ -33,9 +35,11 @@ func newProjectFieldsCache() *projectFieldsCache { func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) error { if c.loaded { - return nil + return c.loadErr } all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + c.loaded = true + c.loadErr = err if err != nil { return err } @@ -47,7 +51,6 @@ func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *github c.byName[key] = append(c.byName[key], f) c.byID[f.ID] = f } - c.loaded = true return nil } @@ -147,6 +150,8 @@ type parsedBatchItem struct { issueNumber int fieldSpec map[string]any // the effective updated_field for this item + fieldKey string + rawValue any err *batchItemError } @@ -169,6 +174,20 @@ func parseBatchItemEntry(index int, raw any, sharedField map[string]any) parsedB } p.fieldSpec = effectiveField + fieldKey, keyErr := fieldSpecKey(effectiveField) + if keyErr != nil { + p.err = &batchItemError{Code: "invalid_updated_field", Message: keyErr.Error()} + return p + } + p.fieldKey = fieldKey + + rawValue, hasValue := effectiveField["value"] + if !hasValue { + p.err = &batchItemError{Code: "invalid_updated_field", Message: "updated_field.value is required"} + return p + } + p.rawValue = rawValue + if refErr := p.parseItemRef(entry); refErr != nil { p.err = &batchItemError{Code: "invalid_item_ref", Message: refErr.Error()} } @@ -212,7 +231,7 @@ func (p *parsedBatchItem) parseItemRef(entry map[string]any) error { p.refKind = batchRefNodeID p.nodeID = s case hasItemID: - id, err := validateAndConvertToInt64(entry["item_id"]) + id, err := validatePositiveInt64(entry["item_id"]) if err != nil { return fmt.Errorf("item_id: %w", err) } @@ -222,8 +241,10 @@ func (p *parsedBatchItem) parseItemRef(entry map[string]any) error { issueOwner, ownerErr := stringFromEntry(entry, "item_owner") issueRepo, repoErr := stringFromEntry(entry, "item_repo") issueNumber, numErr := intFromEntry(entry, "issue_number") - if ownerErr != nil || repoErr != nil || numErr != nil { - return fmt.Errorf("item_owner, item_repo, and issue_number must all be provided together") + 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 @@ -282,16 +303,25 @@ func intFromEntry(entry map[string]any, key string) (int, error) { if !ok { return 0, fmt.Errorf("missing %s", key) } - switch n := v.(type) { - case float64: - return int(n), nil - case int: - return n, nil - case int64: - return int(n), nil - default: - return 0, fmt.Errorf("%s must be a number", 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 } // fieldSpecKey returns a canonical dedup key for an updated_field spec, so the @@ -307,7 +337,7 @@ func fieldSpecKey(spec map[string]any) (string, error) { case !hasID && !hasName: return "", fmt.Errorf("updated_field requires either id or name") case hasID: - id, err := validateAndConvertToInt64(idField) + id, err := validatePositiveInt64(idField) if err != nil { return "", fmt.Errorf("updated_field.id: %w", err) } @@ -351,7 +381,7 @@ func resolveDistinctFields(ctx context.Context, gqlClient *githubv4.Client, cach func resolveFieldSpec(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, spec map[string]any) fieldResolution { if idField, hasID := spec["id"]; hasID { - id, err := validateAndConvertToInt64(idField) + id, err := validatePositiveInt64(idField) if err != nil { return fieldResolution{err: fmt.Errorf("updated_field.id: %w", err)} } @@ -437,16 +467,21 @@ func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2 } func toFloat64(raw any) (float64, bool) { + var number float64 switch v := raw.(type) { case float64: - return v, true + number = v case int: - return float64(v), true + number = float64(v) case int64: - return float64(v), true + number = float64(v) default: return 0, false } + if math.IsNaN(number) || math.IsInf(number, 0) { + return 0, false + } + return number, true } // itemLookupResult is the outcome of resolving one item reference (by numeric @@ -538,10 +573,9 @@ type issueRefKey struct { number int } -// resolveIssueRefs resolves every distinct (owner, repo, issue_number) triplet -// referenced by items to a project item node ID + full database ID, reusing -// the #2914 paginated resolver. Distinct refs are resolved once each. -func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, items []parsedBatchItem) map[issueRefKey]itemLookupResult { +// resolveIssueRefs resolves every distinct issue reference against the already +// resolved project ID. +func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult { out := make(map[issueRefKey]itemLookupResult) for _, it := range items { if it.err != nil || it.refKind != batchRefIssue { @@ -551,7 +585,7 @@ func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, owner, ow if _, done := out[key]; done { continue } - nodeID, itemID, err := resolveProjectItemByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, it.issueOwner, it.issueRepo, it.issueNumber) + nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, it.issueOwner, it.issueRepo, it.issueNumber) out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err} } return out diff --git a/pkg/github/projects_batch_resolve_test.go b/pkg/github/projects_batch_resolve_test.go index 041d8a66e3..37e204e392 100644 --- a/pkg/github/projects_batch_resolve_test.go +++ b/pkg/github/projects_batch_resolve_test.go @@ -1,6 +1,9 @@ package github import ( + "errors" + "math" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -65,6 +68,41 @@ func Test_ParseItemRef_IssueRef(t *testing.T) { assert.Equal(t, 123, p.issueNumber) } +func Test_ParseItemRef_InvalidNumericReferences(t *testing.T) { + issueRef := func(value any) map[string]any { + return map[string]any{ + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": value, + } + } + tests := []struct { + name string + entry map[string]any + }{ + {name: "zero item ID", entry: map[string]any{"item_id": float64(0)}}, + {name: "negative item ID", entry: map[string]any{"item_id": float64(-1)}}, + {name: "fractional item ID", entry: map[string]any{"item_id": float64(1.5)}}, + {name: "NaN item ID", entry: map[string]any{"item_id": math.NaN()}}, + {name: "infinite item ID", entry: map[string]any{"item_id": math.Inf(1)}}, + {name: "overflowing item ID", entry: map[string]any{"item_id": math.MaxFloat64}}, + {name: "zero issue number", entry: issueRef(float64(0))}, + {name: "negative issue number", entry: issueRef(float64(-1))}, + {name: "fractional issue number", entry: issueRef(float64(1.5))}, + {name: "overflowing issue number", entry: issueRef(float64(math.MaxInt32) + 1)}, + {name: "NaN issue number", entry: issueRef(math.NaN())}, + {name: "infinite issue number", entry: issueRef(math.Inf(1))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(tt.entry) + require.Error(t, err) + }) + } +} + func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) { p := parsedBatchItem{} err := p.parseItemRef(map[string]any{"item_owner": "github"}) @@ -105,6 +143,8 @@ func Test_FieldSpecKey(t *testing.T) { {name: "by name case-insensitive dedup", spec: map[string]any{"name": "STATUS", "value": "x"}, want: "name:status"}, {name: "both id and name", spec: map[string]any{"id": float64(1), "name": "Status", "value": "x"}, wantErr: true}, {name: "neither id nor name", spec: map[string]any{"value": "x"}, wantErr: true}, + {name: "zero id", spec: map[string]any{"id": float64(0), "value": "x"}, wantErr: true}, + {name: "negative id", spec: map[string]any{"id": float64(-1), "value": "x"}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -141,6 +181,14 @@ func Test_ConvertProjectFieldValue_Number(t *testing.T) { assert.InDelta(t, 8.0, float64(*v.Number), 0.0001) } +func Test_ConvertProjectFieldValue_Number_NonFinite(t *testing.T) { + field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} + for _, value := range []float64{math.NaN(), math.Inf(-1), math.Inf(1)} { + _, err := convertProjectFieldValue(field, value) + require.Error(t, err) + } +} + func Test_ConvertProjectFieldValue_Date(t *testing.T) { field := &ResolvedField{Name: "Due", DataType: "DATE"} v, err := convertProjectFieldValue(field, "2024-01-15") @@ -241,3 +289,54 @@ func Test_ProjectFieldsCache_ResolveFieldByID_And_ByName(t *testing.T) { _, err = cache.resolveFieldByID(t.Context(), nil, "octo-org", "org", 1, 999) require.Error(t, err) } + +func Test_ProjectFieldsCache_LoadFailureIsCached(t *testing.T) { + transport := &errorGraphQLTransport{err: errors.New("unavailable")} + gqlClient := newTestGQLClient(transport) + items := []parsedBatchItem{ + parseBatchItemEntry(0, map[string]any{ + "node_id": "PVTI_1", + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + }, nil), + parseBatchItemEntry(1, map[string]any{ + "node_id": "PVTI_2", + "updated_field": map[string]any{"name": "Estimate", "value": float64(1)}, + }, nil), + } + + resolved := resolveDistinctFields(t.Context(), gqlClient, newProjectFieldsCache(), "octo-org", "org", 1, items) + + require.Error(t, resolved["name:notes"].err) + require.Error(t, resolved["name:estimate"].err) + assert.Equal(t, 1, transport.calls) +} + +func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing.T) { + tests := []struct { + name string + ownerType string + endpoint string + }{ + {name: "organization", ownerType: "org", endpoint: GetOrgsProjectsV2ItemsByProjectByItemID}, + {name: "user", ownerType: "user", endpoint: GetUsersProjectsV2ItemsByUsernameByProjectByItemID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.endpoint: func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`)) + }, + })) + + resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001}) + + require.NoError(t, resolved[1001].err) + assert.Equal(t, "PVTI_item1001", resolved[1001].nodeID) + assert.Equal(t, 1, calls) + }) + } +} From 283c044acb2b27f6b57831049f02ed371dc7d5a2 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 09:12:23 -0400 Subject: [PATCH 04/11] Strengthen batch project write coverage Cover paginated node-ID capture, deduplicated issue resolution, post-resolution duplicate detection, ambiguous field names, and top-level guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0 --- pkg/github/projects_batch_e2e_test.go | 122 +++++++++++++++++++--- pkg/github/projects_batch_resolve_test.go | 28 +++++ 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_e2e_test.go index 632e184c25..d0e20c7285 100644 --- a/pkg/github/projects_batch_e2e_test.go +++ b/pkg/github/projects_batch_e2e_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "sync/atomic" "testing" @@ -55,6 +56,35 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git ) } +func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { + tooMany := make([]any, MaxProjectItemsPerBatch+1) + validItem := map[string]any{ + "node_id": "PVTI_item1", + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + } + tests := []struct { + name string + args map[string]any + wantErr string + }{ + {name: "missing items", args: map[string]any{}, wantErr: "missing required parameter: items"}, + {name: "non-array items", args: map[string]any{"items": "invalid"}, wantErr: "items must be an array"}, + {name: "empty items", args: map[string]any{"items": []any{}}, wantErr: "items must contain at least one entry"}, + {name: "too many items", args: map[string]any{"items": tooMany}, wantErr: "items exceeds maximum of 100 entries"}, + {name: "malformed shared field", args: map[string]any{"items": []any{validItem}, "updated_field": "invalid"}, wantErr: "updated_field must be an object"}, + {name: "nil GraphQL client", args: map[string]any{"items": []any{validItem}}, wantErr: "gqlClient is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, structured, err := updateProjectItemsBatch(t.Context(), nil, nil, "octo-org", "org", 1, tt.args) + require.NoError(t, err) + assert.Nil(t, structured) + assert.Contains(t, getErrorResult(t, result).Text, tt.wantErr) + }) + } +} + func Test_ProjectsWrite_UpdateProjectItems_NodeIDBypassesRESTLookup(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) @@ -170,7 +200,7 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls), "the same numeric item_id must only be resolved once") } -func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *testing.T) { +func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) queryTransport := githubv4mock.NewMockedHTTPClient( @@ -182,6 +212,34 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test "issueRepo": githubv4.String("planning-tracking"), "issueNumber": githubv4.Int(123), }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": "PVTI_other", + "fullDatabaseId": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": true, "hasPreviousPage": false, + "startCursor": "page-one", "endCursor": "page-one", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssuePageQuery{}, + map[string]any{ + "issueOwner": githubv4.String("github"), + "issueRepo": githubv4.String("planning-tracking"), + "issueNumber": githubv4.Int(123), + "after": githubv4.String("page-one"), + }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ @@ -194,8 +252,8 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test }, }, "pageInfo": map[string]any{ - "hasNextPage": false, "hasPreviousPage": false, - "startCursor": "", "endCursor": "", + "hasNextPage": false, "hasPreviousPage": true, + "startCursor": "page-two", "endCursor": "page-two", }, }, }, @@ -207,15 +265,22 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), + fieldNode("PVTF_estimate", 102, "Estimate", "NUMBER"), })), ), ) transport := &mutationAwareTransport{ t: t, queries: queryTransport.Transport, - mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + require.Len(t, req.Variables, 2) + assert.Equal(t, "PVTI_item2002", req.Variables["input"].(map[string]any)["itemId"]) + assert.Equal(t, "PVTI_item2002", req.Variables["input1"].(map[string]any)["itemId"]) + assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"]) + assert.Equal(t, "PVTF_estimate", req.Variables["input1"].(map[string]any)["fieldId"]) return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ 0: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"}, + 1: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"}, }) }, } @@ -229,9 +294,15 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "updated_field": map[string]any{"name": "Notes", "value": "hello"}, "items": []any{ - map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)}, + map[string]any{ + "item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, + }, + map[string]any{ + "item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123), + "updated_field": map[string]any{"name": "Estimate", "value": float64(3)}, + }, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -240,11 +311,21 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefUsesPaginatedResolver(t *test var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(2), response["succeeded"]) results := response["results"].([]any) - item := results[0].(map[string]any)["item"].(map[string]any) - assert.Equal(t, "2002", item["full_database_id"]) - assert.Len(t, transport.queryCalls, 3, "the project ID, fields, and issue item should each resolve once") + for _, result := range results { + item := result.(map[string]any)["item"].(map[string]any) + assert.Equal(t, "PVTI_item2002", item["node_id"]) + assert.Equal(t, "2002", item["full_database_id"]) + } + issueResolutionCalls := 0 + for _, call := range transport.queryCalls { + if strings.Contains(call.Query, "projectItems") { + issueResolutionCalls++ + } + } + assert.Equal(t, 2, issueResolutionCalls, "duplicate issue refs should share one two-page resolution chain") + assert.Len(t, transport.queryCalls, 4, "expected project, fields, and two issue-page queries") } func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) { @@ -263,16 +344,24 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) transport := &mutationAwareTransport{ t: t, queries: queryTransport.Transport, - mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { - // Only one alias should ever be sent: the duplicate must never reach - // a mutation request. + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + require.Len(t, req.Variables, 1) + assert.Equal(t, 1, strings.Count(req.Query, "updateProjectV2ItemFieldValue")) + assert.Equal(t, "PVTI_item1", req.Variables["input"].(map[string]any)["itemId"]) return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, }) }, } gqlClient := newTestGQLClient(transport) - restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + var restCalls int32 + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&restCalls, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1"}`)) + }, + })) deps := BaseDeps{Client: restClient, GQLClient: gqlClient} handler := toolDef.Handler(deps) @@ -284,7 +373,7 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) "updated_field": map[string]any{"name": "Notes", "value": "hello"}, "items": []any{ map[string]any{"node_id": "PVTI_item1"}, - map[string]any{"node_id": "PVTI_item1"}, // same item + same field -> duplicate + map[string]any{"item_id": float64(1001)}, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -300,7 +389,8 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) second := results[1].(map[string]any) assert.Equal(t, "failed", second["status"]) assert.Equal(t, "duplicate_target", second["error"].(map[string]any)["code"]) - assert.Equal(t, 1, len(transport.mutationCalls)) + assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls)) + assert.Len(t, transport.mutationCalls, 1) } func Test_ProjectsWrite_UpdateProjectItems_TwentyWritesIsOneMutationRequest(t *testing.T) { diff --git a/pkg/github/projects_batch_resolve_test.go b/pkg/github/projects_batch_resolve_test.go index 37e204e392..ef5833311b 100644 --- a/pkg/github/projects_batch_resolve_test.go +++ b/pkg/github/projects_batch_resolve_test.go @@ -1,11 +1,14 @@ package github import ( + "encoding/json" "errors" "math" "net/http" "testing" + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -290,6 +293,31 @@ func Test_ProjectFieldsCache_ResolveFieldByID_And_ByName(t *testing.T) { require.Error(t, err) } +func Test_ProjectFieldsCache_ResolveFieldByName_Ambiguous(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status1", 101, "Status", nil), + statusFieldNode("PVTSSF_status2", 202, "Status", nil), + })), + ), + ) + + _, err := newProjectFieldsCache().resolveFieldByName(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, "status") + require.Error(t, err) + + var response struct { + Error string `json:"error"` + Candidates []map[string]any `json:"candidates"` + } + require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) + assert.Equal(t, "field_ambiguous", response.Error) + require.Len(t, response.Candidates, 2) + assert.ElementsMatch(t, []any{"101", "202"}, []any{response.Candidates[0]["id"], response.Candidates[1]["id"]}) +} + func Test_ProjectFieldsCache_LoadFailureIsCached(t *testing.T) { transport := &errorGraphQLTransport{err: errors.New("unavailable")} gqlClient := newTestGQLClient(transport) From 6049621fd142e55dbd77ed95b537358a064694e4 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 09:50:43 -0400 Subject: [PATCH 05/11] Express batch item references with oneOf Model node, numeric, and issue references as closed schema variants while retaining runtime validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0 --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 84 ++++++++++++++------ pkg/github/projects.go | 81 +++++++++++-------- pkg/github/projects_test.go | 36 +++++++++ 4 files changed, 147 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 3414416805..7499fc9877 100644 --- a/README.md +++ b/README.md @@ -1119,7 +1119,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 set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call. (object[], optional) + - `items`: The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 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) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index cb615fcffa..2414777a98 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -41,35 +41,71 @@ "type": "string" }, "items": { - "description": "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: 100 items per call.", + "description": "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 items per call.", "items": { - "additionalProperties": false, - "properties": { - "issue_number": { - "description": "Issue number used to resolve the project item together with item_owner and item_repo.", - "type": "integer" - }, - "item_id": { - "description": "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", - "type": "integer" - }, - "item_owner": { - "description": "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.", - "type": "string" - }, - "item_repo": { - "description": "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.", - "type": "string" + "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" + }, + "updated_field": { + "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + "type": "object" + } + }, + "required": [ + "node_id" + ], + "type": "object" }, - "node_id": { - "description": "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", - "type": "string" + { + "additionalProperties": false, + "properties": { + "item_id": { + "description": "The numeric project item ID.", + "type": "integer" + }, + "updated_field": { + "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + "type": "object" + } + }, + "required": [ + "item_id" + ], + "type": "object" }, - "updated_field": { - "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + { + "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" + }, + "updated_field": { + "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + "type": "object" + } + }, + "required": [ + "item_owner", + "item_repo", + "issue_number" + ], "type": "object" } - }, + ], "type": "object" }, "type": "array" diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 3cd65892d5..bac332f7f0 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -495,6 +495,54 @@ Use this tool to get details about individual projects, project fields, and proj return tool } +func updateProjectItemsItemSchema() *jsonschema.Schema { + updatedField := &jsonschema.Schema{ + Type: "object", + Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", + } + variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { + properties["updated_field"] = updatedField + 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.", + }, + }), + }, + } +} + // ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( @@ -571,37 +619,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "items": { Type: "array", - Description: "The set of items to update. Required for 'update_project_items'. Each entry references an existing item by exactly one of: 'node_id' (the item's GraphQL node ID, e.g. as returned by 'list_project_items' or 'add_project_item'), 'item_id' (the numeric project item ID), or (item_owner + item_repo + issue_number). Each entry may optionally include its own 'updated_field'; if omitted, the top-level 'updated_field' is applied. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", - Items: &jsonschema.Schema{ - Type: "object", - AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, - Properties: map[string]*jsonschema.Schema{ - "node_id": { - Type: "string", - Description: "The project item's GraphQL node ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", - }, - "item_id": { - Type: "integer", - Description: "The project item ID. Provide exactly one of node_id, item_id, or (item_owner + item_repo + issue_number).", - }, - "item_owner": { - Type: "string", - Description: "Owner of the repository containing the issue. Combine with item_repo and issue_number to resolve the item.", - }, - "item_repo": { - Type: "string", - Description: "Repository containing the issue. Combine with item_owner and issue_number to resolve the item.", - }, - "issue_number": { - Type: "integer", - Description: "Issue number used to resolve the project item together with item_owner and item_repo.", - }, - "updated_field": { - Type: "object", - Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", - }, - }, - }, + Description: "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", + Items: updateProjectItemsItemSchema(), }, "body": { Type: "string", diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 6908681492..519e80e494 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -888,6 +888,42 @@ func Test_ProjectsWrite(t *testing.T) { assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) } +func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) { + inputSchema := ProjectsWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) + 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", "updated_field"}, + {"item_id", "updated_field"}, + {"item_owner", "item_repo", "issue_number", "updated_field"}, + } + 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) + assert.Contains(t, variant.Properties, "updated_field") + 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") + } +} + func Test_ProjectsWrite_AddProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) From 5c269f17fde58c66ce2067aef7fb027b4dc6eb98 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 10:02:41 -0400 Subject: [PATCH 06/11] Trim batch implementation commentary Remove redundant implementation narration while retaining comments for non-obvious client and failure semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- pkg/github/projects.go | 10 +---- pkg/github/projects_batch.go | 54 +++++------------------- pkg/github/projects_batch_e2e_test.go | 2 +- pkg/github/projects_batch_resolve.go | 61 +++------------------------ 4 files changed, 20 insertions(+), 107 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index bac332f7f0..95d07645d9 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -32,10 +32,7 @@ const ( ProjectStatusUpdateCreateFailedError = "failed to create project status update" ProjectResolveIDFailedError = "failed to resolve project ID" MaxProjectsPerPage = 50 - // MaxProjectItemsPerBatch caps the number of items processed in a single - // update_project_items call. This keeps a batch bounded, prevents runaway - // resolution costs, and matches the practical scale of typical triage sweeps. - MaxProjectItemsPerBatch = 100 + maxProjectItemsPerBatch = 100 ) // Method constants for consolidated project tools @@ -619,7 +616,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "items": { Type: "array", - Description: "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: " + strconv.Itoa(MaxProjectItemsPerBatch) + " items per call.", + Description: "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: " + strconv.Itoa(maxProjectItemsPerBatch) + " items per call.", Items: updateProjectItemsItemSchema(), }, "body": { @@ -1596,9 +1593,6 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input) } -// buildUpdateProjectItemWithCache is the cache-aware variant of buildUpdateProjectItem. -// When cache is non-nil, name-based field lookups reuse the cache's in-memory index; when -// nil, each name lookup performs its own GraphQL round trip (single-item behaviour). func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 71b2d94455..13e4a7b9a6 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -11,13 +11,8 @@ import ( "github.com/shurcooL/githubv4" ) -// batchItemStatus is the tri-state outcome of one item in update_project_items. -// - succeeded: the item's alias came back populated, confirming the write. -// - failed: a deterministic failure before any mutation was sent (invalid -// input, resolution failure, value-conversion failure, duplicate target). -// - unknown: a mutation touching this item was not confirmed, or was never -// sent after an earlier systemic failure. The pinned client drops -// errors[].path, so we don't retry or issue a per-item fallback mutation. +// Unknown outcomes cannot be attributed or retried safely because the pinned +// client drops errors[].path. type batchItemStatus string const ( @@ -26,21 +21,15 @@ const ( batchItemUnknown batchItemStatus = "unknown" ) -// batchItemResult is the per-item outcome returned by update_project_items. type batchItemResult struct { Index int `json:"index"` Status batchItemStatus `json:"status"` Item *batchItemIdentity `json:"item,omitempty"` Error *batchItemError `json:"error,omitempty"` - // Ref echoes the identifying input for the item so agents can correlate results - // with the request even when resolution fails. + // Ref preserves the request identity when resolution fails. Ref map[string]any `json:"ref,omitempty"` } -// batchItemIdentity is the minimal item identity echoed back for a confirmed -// write: the mutation response's `projectV2Item { id, fullDatabaseId }`, plus -// the numeric item_id the request used (when known), so callers can correlate -// without a follow-up read. type batchItemIdentity struct { NodeID string `json:"node_id,omitempty"` FullDatabaseID string `json:"full_database_id,omitempty"` @@ -54,21 +43,14 @@ type batchItemError struct { Hint string `json:"hint,omitempty"` } -// resolvedBatchItem is a fully-resolved, validated write, ready to be included -// in an aliased update or clear mutation. type resolvedBatchItem struct { index int nodeID string fullDatabaseID int64 fieldNodeID string - value githubv4.ProjectV2FieldValue // unused when this is a clear + value githubv4.ProjectV2FieldValue } -// updateProjectItemsBatch handles the update_project_items method. It resolves -// the project, every distinct field, and every item once up front, then writes -// via chunked, aliased updateProjectV2ItemFieldValue / clearProjectV2ItemFieldValue -// GraphQL mutations (pkg/github/projects_batch_mutation.go) instead of one REST -// PATCH per item. 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 { @@ -81,8 +63,8 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie 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 + if len(itemsRaw) > maxProjectItemsPerBatch { + return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", maxProjectItemsPerBatch, len(itemsRaw))), nil, nil } var sharedField map[string]any @@ -116,17 +98,14 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie return newUpdateProjectItemsResult(results) } - // Resolve the project node ID once; every mutation input needs it. projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - // Resolve every distinct effective field once. cache := newProjectFieldsCache() fieldResolutions := resolveDistinctFields(ctx, gqlClient, cache, owner, ownerType, projectNumber, parsed) - // Resolve numeric item_id -> node id, deduplicated with bounded concurrency. var numericIDs []int64 for _, p := range parsed { if p.err == nil && p.refKind == batchRefItemID { @@ -135,16 +114,14 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie } itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs) - // Resolve issue-ref items, deduplicated. issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed) - // Build the final resolved plan, rejecting duplicate item+field targets. var updates, clears []resolvedBatchItem - seenTargets := make(map[string]int) // "nodeID|fieldNodeID" -> first index that claimed it + seenTargets := make(map[string]int) for i, p := range parsed { if p.err != nil { - continue // deterministic parse failure already recorded above + continue } fr := fieldResolutions[p.fieldKey] @@ -187,9 +164,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie updates = append(updates, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID, value: value}) } - // Execute update mutations, then clear mutations, in sequential chunks. - // Clears never run if the update phase aborted: we do not know whether an - // ambiguous failure landed, so we do not risk further writes this call. + // Skip clears after an ambiguous update failure because an earlier write may have landed. aborted := executeBatchPartition(ctx, gqlClient, batchMutationUpdate, projectID, updates, results, parsed) if !aborted { executeBatchPartition(ctx, gqlClient, batchMutationClear, projectID, clears, results, parsed) @@ -232,9 +207,6 @@ func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult return result, nil, nil } -// resolveItemReference maps a parsed item's reference to its project item node -// ID and (when known) numeric full database ID, using the pre-computed lookup -// tables for the item_id and issue-ref cases. func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult) (nodeID string, fullDatabaseID int64, err error) { switch p.refKind { case batchRefNodeID: @@ -257,12 +229,8 @@ func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupR } } -// executeBatchPartition executes a single partition (all updates, or all -// clears) in sequential chunks of at most batchMutationWireChunkSize. It -// writes outcomes directly into results (keyed by original index) and returns -// true if execution was aborted before every item in the partition was -// attempted, because an earlier chunk's failure looked systemic (transport -// failure or context cancellation) rather than a per-mutation GraphQL error. +// Transport, cancellation, or incomplete-data ambiguity stops later chunks; +// GraphQL response errors do not because populated aliases still confirm writes. func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, projectID githubv4.ID, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) bool { for start := 0; start < len(items); start += batchMutationWireChunkSize { if ctx.Err() != nil { diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_e2e_test.go index d0e20c7285..1753044612 100644 --- a/pkg/github/projects_batch_e2e_test.go +++ b/pkg/github/projects_batch_e2e_test.go @@ -57,7 +57,7 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git } func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { - tooMany := make([]any, MaxProjectItemsPerBatch+1) + tooMany := make([]any, maxProjectItemsPerBatch+1) validItem := map[string]any{ "node_id": "PVTI_item1", "updated_field": map[string]any{"name": "Notes", "value": "hello"}, diff --git a/pkg/github/projects_batch_resolve.go b/pkg/github/projects_batch_resolve.go index c541e48869..36f9777b02 100644 --- a/pkg/github/projects_batch_resolve.go +++ b/pkg/github/projects_batch_resolve.go @@ -14,13 +14,8 @@ import ( "github.com/shurcooL/githubv4" ) -// batchItemLookupConcurrency bounds concurrent REST GETs used to resolve -// numeric item_id values to project item node IDs. const batchItemLookupConcurrency = 5 -// projectFieldsCache lazily loads every field on a project once, so callers -// that need to resolve multiple field names/IDs against the same project pay -// a single GraphQL round trip instead of one per lookup. type projectFieldsCache struct { loaded bool loadErr error @@ -54,8 +49,6 @@ func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *github return nil } -// resolveFieldByName is a cached variant of resolveProjectFieldByName: it uses the -// cache's in-memory index instead of querying GraphQL per lookup. func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName string) (*ResolvedField, error) { if fieldName == "" { return nil, fmt.Errorf("field name must not be empty") @@ -92,9 +85,6 @@ func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient * } } -// resolveFieldByID is a cached variant of resolveProjectFieldByName's numeric -// counterpart: it uses the cache's in-memory index instead of a fresh -// GraphQL round trip per lookup. func (c *projectFieldsCache) resolveFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) { if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { return nil, err @@ -124,8 +114,6 @@ func (c *projectFieldsCache) fieldCandidates() []any { return candidates } -// batchItemRefKind identifies which of the mutually exclusive item-reference -// shapes a batch entry used. type batchItemRefKind int const ( @@ -134,29 +122,24 @@ const ( batchRefIssue ) -// parsedBatchItem is the result of validating one items[] entry, before any -// network resolution has happened. err, if set, is a deterministic failure -// (bad shape, missing/ambiguous reference form, malformed updated_field). type parsedBatchItem struct { index int ref map[string]any refKind batchItemRefKind - nodeID string // set when refKind == batchRefNodeID - itemID int64 // set when refKind == batchRefItemID + nodeID string + itemID int64 - issueOwner string // set when refKind == batchRefIssue + issueOwner string issueRepo string issueNumber int - fieldSpec map[string]any // the effective updated_field for this item + fieldSpec map[string]any fieldKey string rawValue any err *batchItemError } -// parseBatchItemEntry validates one raw items[] entry and determines its -// reference kind. It performs no network I/O. func parseBatchItemEntry(index int, raw any, sharedField map[string]any) parsedBatchItem { p := parsedBatchItem{index: index} @@ -194,8 +177,6 @@ func parseBatchItemEntry(index int, raw any, sharedField map[string]any) parsedB return p } -// parseItemRef reads the item-reference fields from entry, requiring exactly -// one of: node_id, item_id, or (item_owner + item_repo + issue_number). func (p *parsedBatchItem) parseItemRef(entry map[string]any) error { _, hasNodeID := entry["node_id"] _, hasItemID := entry["item_id"] @@ -254,9 +235,6 @@ func (p *parsedBatchItem) parseItemRef(entry map[string]any) error { return nil } -// effectiveUpdatedField picks the per-item updated_field when set, otherwise falls -// back to the shared top-level value. Returns an error if neither is present or the -// per-item override is malformed. func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) { if raw, ok := entry["updated_field"]; ok { m, isMap := raw.(map[string]any) @@ -271,8 +249,6 @@ func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) return nil, fmt.Errorf("updated_field is required either at the top level or on each item") } -// itemRefEcho returns a copy of the identifying fields of an item entry, used to -// help agents correlate per-item results with their inputs. 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"} { @@ -324,9 +300,6 @@ func validatePositiveInt64(value any) (int64, error) { return n, nil } -// fieldSpecKey returns a canonical dedup key for an updated_field spec, so the -// batch resolves each distinct field at most once regardless of how many -// items reference it. func fieldSpecKey(spec map[string]any) (string, error) { idField, hasID := spec["id"] nameField, hasName := spec["name"] @@ -351,15 +324,11 @@ func fieldSpecKey(spec map[string]any) (string, error) { } } -// fieldResolution is the outcome of resolving one distinct field spec. type fieldResolution struct { field *ResolvedField err error } -// resolveDistinctFields resolves every distinct effective updated_field spec -// referenced by items exactly once, using cache to amortise the underlying -// fields(...) GraphQL round trip across the whole batch. func resolveDistinctFields(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, items []parsedBatchItem) map[string]fieldResolution { resolved := make(map[string]fieldResolution) for _, it := range items { @@ -368,7 +337,6 @@ func resolveDistinctFields(ctx context.Context, gqlClient *githubv4.Client, cach } key, keyErr := fieldSpecKey(it.fieldSpec) if keyErr != nil { - // Surfaced again per-item in the caller; nothing to cache here. continue } if _, done := resolved[key]; done { @@ -393,12 +361,6 @@ func resolveFieldSpec(ctx context.Context, gqlClient *githubv4.Client, cache *pr return fieldResolution{field: f, err: err} } -// convertProjectFieldValue converts a raw JSON value into the -// githubv4.ProjectV2FieldValue member appropriate for field's data type. -// Supported types are TEXT, NUMBER, DATE, SINGLE_SELECT, and ITERATION; -// anything else (e.g. ASSIGNEES, LABELS, TITLE) returns a deterministic error -// directing the caller to update_project_item, which delegates conversion to -// the REST API. func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) { var zero githubv4.ProjectV2FieldValue @@ -484,19 +446,14 @@ func toFloat64(raw any) (float64, bool) { return number, true } -// itemLookupResult is the outcome of resolving one item reference (by numeric -// item_id or by issue ref) to a project item's GraphQL node ID. type itemLookupResult struct { nodeID string fullDatabaseID int64 err error } -// resolveItemNodeIDsByNumericID resolves a set of numeric item_id values to -// their project item node IDs via REST GETs, deduplicating repeated IDs and -// bounding concurrency to batchItemLookupConcurrency. Each lookup's error (if -// any) is independent: one failing ID does not cancel the others, and ctx -// cancellation is still respected for in-flight and not-yet-started lookups. +// 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 @@ -566,15 +523,12 @@ func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, o return out } -// issueRefKey dedupes issue-ref lookups shared by more than one batch item. type issueRefKey struct { owner string repo string number int } -// resolveIssueRefs resolves every distinct issue reference against the already -// resolved project ID. func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult { out := make(map[issueRefKey]itemLookupResult) for _, it := range items { @@ -591,9 +545,6 @@ func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID return out } -// batchErrorFromResolution converts an error from resolution/build helpers into a -// batch-item error. Structured resolution errors preserve their code, hint, and -// candidates so agents can self-correct per-item without failing the batch. func batchErrorFromResolution(err error) *batchItemError { var structured *ghErrors.StructuredResolutionError if errors.As(err, &structured) { From e5e4a719a258ca902d884403027629aadf556103 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 10:29:44 -0400 Subject: [PATCH 07/11] Apply one field update per batch Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7ae767ff-c1d0-46a9-b126-2e91403993a0 --- README.md | 4 +- pkg/github/__toolsnaps__/projects_write.snap | 52 +++-- pkg/github/projects.go | 56 +++-- pkg/github/projects_batch.go | 97 ++++---- pkg/github/projects_batch_e2e_test.go | 169 ++++++++------ pkg/github/projects_batch_resolve.go | 233 +++++-------------- pkg/github/projects_batch_resolve_test.go | 134 ++++------- pkg/github/projects_test.go | 31 ++- 8 files changed, 347 insertions(+), 429 deletions(-) diff --git a/README.md b/README.md index 7499fc9877..63f14abdb5 100644 --- a/README.md +++ b/README.md @@ -1119,7 +1119,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. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 items per call. (object[], optional) + - `items`: The items to update with the top-level 'updated_field'. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 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) @@ -1131,7 +1131,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'. For 'update_project_items', provide a shared shape at the top level or override it per 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) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 2414777a98..9f1b477c69 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -41,7 +41,7 @@ "type": "string" }, "items": { - "description": "The items to update. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: 100 items per call.", + "description": "The items to update with the top-level 'updated_field'. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 items per call.", "items": { "oneOf": [ { @@ -50,10 +50,6 @@ "node_id": { "description": "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.", "type": "string" - }, - "updated_field": { - "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", - "type": "object" } }, "required": [ @@ -67,10 +63,6 @@ "item_id": { "description": "The numeric project item ID.", "type": "integer" - }, - "updated_field": { - "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", - "type": "object" } }, "required": [ @@ -92,10 +84,6 @@ "item_repo": { "description": "Repository containing the issue.", "type": "string" - }, - "updated_field": { - "description": "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", - "type": "object" } }, "required": [ @@ -198,7 +186,43 @@ "type": "string" }, "updated_field": { - "description": "Object describing the field to update and its new value. Required for 'update_project_item'. For 'update_project_items', provide a shared shape at the top level or override it per 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" } }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 95d07645d9..bd266c7fe1 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -493,12 +493,7 @@ Use this tool to get details about individual projects, project fields, and proj } func updateProjectItemsItemSchema() *jsonschema.Schema { - updatedField := &jsonschema.Schema{ - Type: "object", - Description: "Per-item field update. Overrides the top-level 'updated_field'. Same shape as the top-level 'updated_field'.", - } variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { - properties["updated_field"] = updatedField return &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -540,6 +535,40 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } } +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( @@ -610,13 +639,10 @@ 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'. For 'update_project_items', provide a shared shape at the top level or override it per 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. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Each entry may include 'updated_field'; otherwise the top-level 'updated_field' applies. Limit: " + strconv.Itoa(maxProjectItemsPerBatch) + " items per call.", + Description: "The items to update with the top-level 'updated_field'. Required for 'update_project_items'. 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": { @@ -1590,10 +1616,6 @@ func validateAndConvertToInt64(value any) (int64, error) { // buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { - return buildUpdateProjectItemWithCache(ctx, gqlClient, nil, owner, ownerType, projectNumber, input) -} - -func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } @@ -1633,11 +1655,7 @@ func buildUpdateProjectItemWithCache(ctx context.Context, gqlClient *githubv4.Cl return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") } var err error - if cache != nil { - resolved, err = cache.resolveFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName) - } else { - resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") - } + resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") if err != nil { return nil, err } diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 13e4a7b9a6..08e1244505 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -3,8 +3,10 @@ package github import ( "context" "encoding/json" + "errors" "fmt" + 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" @@ -47,8 +49,6 @@ type resolvedBatchItem struct { index int nodeID string fullDatabaseID int64 - fieldNodeID string - 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) { @@ -67,13 +67,13 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie return utils.NewToolResultError(fmt.Sprintf("items exceeds maximum of %d entries per call (got %d)", maxProjectItemsPerBatch, len(itemsRaw))), nil, nil } - var sharedField map[string]any - if rawShared, hasShared := args["updated_field"]; hasShared { - if m, ok := rawShared.(map[string]any); ok && m != nil { - sharedField = m - } else { - return utils.NewToolResultError("updated_field must be an object"), 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 { @@ -82,7 +82,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie parsed := make([]parsedBatchItem, len(itemsRaw)) for i, raw := range itemsRaw { - parsed[i] = parseBatchItemEntry(i, raw, sharedField) + parsed[i] = parseBatchItemEntry(i, raw) } results := make([]batchItemResult, len(itemsRaw)) @@ -103,8 +103,21 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie return utils.NewToolResultError(err.Error()), nil, nil } - cache := newProjectFieldsCache() - fieldResolutions := resolveDistinctFields(ctx, gqlClient, cache, owner, ownerType, projectNumber, parsed) + 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 { @@ -116,7 +129,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed) - var updates, clears []resolvedBatchItem + var work []resolvedBatchItem seenTargets := make(map[string]int) for i, p := range parsed { @@ -124,57 +137,40 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - fr := fieldResolutions[p.fieldKey] - if fr.err != nil { - results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(fr.err)} - 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 } - targetKey := nodeID + "|" + fr.field.NodeID - if firstIndex, dup := seenTargets[targetKey]; dup { + 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 item and field as items[%d]; each item+field pair may only be written once per call", i, firstIndex), + 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 } - if p.rawValue == nil { - seenTargets[targetKey] = i - clears = append(clears, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID}) - continue - } - - value, convErr := convertProjectFieldValue(fr.field, p.rawValue) - if convErr != nil { - results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(convErr)} - continue - } - - seenTargets[targetKey] = i - updates = append(updates, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID, fieldNodeID: fr.field.NodeID, value: value}) + seenTargets[nodeID] = i + work = append(work, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID}) } - // Skip clears after an ambiguous update failure because an earlier write may have landed. - aborted := executeBatchPartition(ctx, gqlClient, batchMutationUpdate, projectID, updates, results, parsed) - if !aborted { - executeBatchPartition(ctx, gqlClient, batchMutationClear, projectID, clears, results, parsed) - } else { - markChunkUnknown(clears, results, parsed, fmt.Errorf("batch aborted before this item's mutation was sent, due to an earlier ambiguous transport failure or context cancellation")) - } + executeBatchWrites(ctx, gqlClient, kind, projectID, githubv4.ID(field.NodeID), value, work, results, parsed) 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 { @@ -231,11 +227,11 @@ func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupR // Transport, cancellation, or incomplete-data ambiguity stops later chunks; // GraphQL response errors do not because populated aliases still confirm writes. -func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, projectID githubv4.ID, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) bool { +func executeBatchWrites(ctx context.Context, gqlClient *githubv4.Client, kind batchMutationKind, projectID, fieldID githubv4.ID, value githubv4.ProjectV2FieldValue, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) { for start := 0; start < len(items); start += batchMutationWireChunkSize { if ctx.Err() != nil { markChunkUnknown(items[start:], results, parsed, ctx.Err()) - return true + return } end := min(start+batchMutationWireChunkSize, len(items)) @@ -247,14 +243,14 @@ func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind inputs[i] = githubv4.ClearProjectV2ItemFieldValueInput{ ProjectID: projectID, ItemID: githubv4.ID(item.nodeID), - FieldID: githubv4.ID(item.fieldNodeID), + FieldID: fieldID, } } else { inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{ ProjectID: projectID, ItemID: githubv4.ID(item.nodeID), - FieldID: githubv4.ID(item.fieldNodeID), - Value: item.value, + FieldID: fieldID, + Value: value, } } } @@ -285,15 +281,14 @@ func executeBatchPartition(ctx context.Context, gqlClient *githubv4.Client, kind if mutateErr != nil { markChunkUnknown(items[start:], results, parsed, mutateErr) - return true + return } if populated != len(chunk) { markChunkUnknown(items[start:], results, parsed, fmt.Errorf("mutation response did not include every item")) - return true + return } } - return false } func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasOutcome, results []batchItemResult, parsed []parsedBatchItem, err error) { diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_e2e_test.go index 1753044612..af27932d63 100644 --- a/pkg/github/projects_batch_e2e_test.go +++ b/pkg/github/projects_batch_e2e_test.go @@ -58,10 +58,8 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { tooMany := make([]any, maxProjectItemsPerBatch+1) - validItem := map[string]any{ - "node_id": "PVTI_item1", - "updated_field": map[string]any{"name": "Notes", "value": "hello"}, - } + validItem := map[string]any{"node_id": "PVTI_item1"} + validField := map[string]any{"name": "Notes", "value": "hello"} tests := []struct { name string args map[string]any @@ -71,8 +69,12 @@ func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { {name: "non-array items", args: map[string]any{"items": "invalid"}, wantErr: "items must be an array"}, {name: "empty items", args: map[string]any{"items": []any{}}, wantErr: "items must contain at least one entry"}, {name: "too many items", args: map[string]any{"items": tooMany}, wantErr: "items exceeds maximum of 100 entries"}, - {name: "malformed shared field", args: map[string]any{"items": []any{validItem}, "updated_field": "invalid"}, wantErr: "updated_field must be an object"}, - {name: "nil GraphQL client", args: map[string]any{"items": []any{validItem}}, wantErr: "gqlClient is required"}, + {name: "missing updated field", args: map[string]any{"items": []any{validItem}}, wantErr: "missing required parameter: updated_field"}, + {name: "malformed updated field", args: map[string]any{"items": []any{validItem}, "updated_field": "invalid"}, wantErr: "updated_field must be an object"}, + {name: "missing field value", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"name": "Notes"}}, wantErr: "updated_field.value is required"}, + {name: "missing field reference", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"value": "hello"}}, wantErr: "updated_field requires either id or name"}, + {name: "ambiguous field reference", args: map[string]any{"items": []any{validItem}, "updated_field": map[string]any{"id": float64(1), "name": "Notes", "value": "hello"}}, wantErr: "updated_field must set either id or name"}, + {name: "nil GraphQL client", args: map[string]any{"items": []any{validItem}, "updated_field": validField}, wantErr: "gqlClient is required"}, } for _, tt := range tests { @@ -85,6 +87,50 @@ func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { } } +func Test_UpdateProjectItemsBatch_InvalidSharedValueIsTopLevelError(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{ + {"id": "OPT_todo", "name": "Todo"}, + }), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + t.Fatal("invalid shared values must fail before writes") + return http.StatusInternalServerError, "" + }, + } + + result, structured, err := updateProjectItemsBatch( + t.Context(), + nil, + newTestGQLClient(transport), + "octo-org", + "org", + 1, + map[string]any{ + "updated_field": map[string]any{"name": "Status", "value": "Missing"}, + "items": []any{map[string]any{"node_id": "PVTI_item1"}}, + }, + ) + require.NoError(t, err) + assert.Nil(t, structured) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getErrorResult(t, result).Text), &response)) + assert.Equal(t, "option_not_found", response["error"]) + assert.Equal(t, "Missing", response["name"]) + assert.Equal(t, []any{map[string]any{"name": "Todo"}}, response["candidates"]) + assert.Empty(t, transport.mutationCalls) +} + func Test_ProjectsWrite_UpdateProjectItems_NodeIDBypassesRESTLookup(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) @@ -148,7 +194,6 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), - fieldNode("PVTF_estimate", 102, "Estimate", "NUMBER"), })), ), ) @@ -156,14 +201,11 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t t: t, queries: queryTransport.Transport, mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + require.Len(t, req.Variables, 1) assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"]) - assert.Equal(t, "PVTF_estimate", req.Variables["input1"].(map[string]any)["fieldId"]) - ids := map[int]struct{ NodeID, FullDatabaseID string }{} - // Two items in this chunk (item0, item1), sharing the same - // underlying project item. - ids[0] = struct{ NodeID, FullDatabaseID string }{NodeID: "PVTI_item1001", FullDatabaseID: "1001"} - ids[1] = struct{ NodeID, FullDatabaseID string }{NodeID: "PVTI_item1001", FullDatabaseID: "1001"} - return http.StatusOK, mutationDataResponse(t, ids) + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item1001", FullDatabaseID: "1001"}, + }) }, } gqlClient := newTestGQLClient(transport) @@ -185,9 +227,10 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t "owner": "octo-org", "owner_type": "org", "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, "items": []any{ - map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"id": float64(101), "value": "hello"}}, - map[string]any{"item_id": float64(1001), "updated_field": map[string]any{"name": "Estimate", "value": float64(3)}}, + map[string]any{"item_id": float64(1001)}, + map[string]any{"item_id": float64(1001)}, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -196,8 +239,11 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(2), response["succeeded"]) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls), "the same numeric item_id must only be resolved once") + results := response["results"].([]any) + assert.Equal(t, "duplicate_target", results[1].(map[string]any)["error"].(map[string]any)["code"]) } func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *testing.T) { @@ -265,7 +311,6 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *t fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), - fieldNode("PVTF_estimate", 102, "Estimate", "NUMBER"), })), ), ) @@ -273,14 +318,11 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *t t: t, queries: queryTransport.Transport, mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { - require.Len(t, req.Variables, 2) + require.Len(t, req.Variables, 1) assert.Equal(t, "PVTI_item2002", req.Variables["input"].(map[string]any)["itemId"]) - assert.Equal(t, "PVTI_item2002", req.Variables["input1"].(map[string]any)["itemId"]) assert.Equal(t, "PVTF_notes", req.Variables["input"].(map[string]any)["fieldId"]) - assert.Equal(t, "PVTF_estimate", req.Variables["input1"].(map[string]any)["fieldId"]) return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ 0: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"}, - 1: {NodeID: "PVTI_item2002", FullDatabaseID: "2002"}, }) }, } @@ -294,14 +336,13 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *t "owner": "octo-org", "owner_type": "org", "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "hello"}, "items": []any{ map[string]any{ "item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123), - "updated_field": map[string]any{"name": "Notes", "value": "hello"}, }, map[string]any{ "item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123), - "updated_field": map[string]any{"name": "Estimate", "value": float64(3)}, }, }, }) @@ -311,13 +352,13 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *t var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(2), response["succeeded"]) + assert.Equal(t, float64(1), response["succeeded"]) + assert.Equal(t, float64(1), response["failed"]) results := response["results"].([]any) - for _, result := range results { - item := result.(map[string]any)["item"].(map[string]any) - assert.Equal(t, "PVTI_item2002", item["node_id"]) - assert.Equal(t, "2002", item["full_database_id"]) - } + item := results[0].(map[string]any)["item"].(map[string]any) + assert.Equal(t, "PVTI_item2002", item["node_id"]) + assert.Equal(t, "2002", item["full_database_id"]) + assert.Equal(t, "duplicate_target", results[1].(map[string]any)["error"].(map[string]any)["code"]) issueResolutionCalls := 0 for _, call := range transport.queryCalls { if strings.Contains(call.Query, "projectItems") { @@ -466,7 +507,7 @@ func chunkSizeTestRun(t *testing.T, toolDef inventory.ServerTool, itemCount int) return transport } -func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t *testing.T) { +func Test_ProjectsWrite_UpdateProjectItems_SharedNullClearsAllItemsInOrder(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) queryTransport := githubv4mock.NewMockedHTTPClient( @@ -482,27 +523,17 @@ func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t * transport := &mutationAwareTransport{ t: t, queries: queryTransport.Transport, - mutationRespond: func(callIndex int, req capturedGraphQLRequest) (int, string) { - switch callIndex { - case 0: - // Update partition: original items 0 and 2 (item1 is a clear). - assert.Contains(t, req.Query, "updateProjectV2ItemFieldValue") - assert.NotContains(t, req.Query, "clearProjectV2ItemFieldValue") - return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ - 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, - 1: {NodeID: "PVTI_item2", FullDatabaseID: "1002"}, - }) - case 1: - // Clear partition: original item 1. - assert.Contains(t, req.Query, "clearProjectV2ItemFieldValue") - assert.NotContains(t, req.Variables["input"].(map[string]any), "value") - return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ - 0: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, - }) - default: - t.Fatalf("unexpected mutation call #%d", callIndex) - return http.StatusInternalServerError, "" + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "clearProjectV2ItemFieldValue") + assert.NotContains(t, req.Query, "updateProjectV2ItemFieldValue") + for _, input := range req.Variables { + assert.NotContains(t, input.(map[string]any), "value") } + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, + 1: {NodeID: "PVTI_item1", FullDatabaseID: "1001"}, + 2: {NodeID: "PVTI_item2", FullDatabaseID: "1002"}, + }) }, } gqlClient := newTestGQLClient(transport) @@ -515,11 +546,11 @@ func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t * "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "updated_field": map[string]any{"name": "Notes"}, + "updated_field": map[string]any{"name": "Notes", "value": nil}, "items": []any{ - map[string]any{"node_id": "PVTI_item0", "updated_field": map[string]any{"name": "Notes", "value": "a"}}, - map[string]any{"node_id": "PVTI_item1", "updated_field": map[string]any{"name": "Notes", "value": nil}}, - map[string]any{"node_id": "PVTI_item2", "updated_field": map[string]any{"name": "Notes", "value": "b"}}, + map[string]any{"node_id": "PVTI_item0"}, + map[string]any{"node_id": "PVTI_item1"}, + map[string]any{"node_id": "PVTI_item2"}, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -534,12 +565,11 @@ func Test_ProjectsWrite_UpdateProjectItems_MixedUpdateAndClearPreservesOrder(t * require.Len(t, results, 3) for i, r := range results { entry := r.(map[string]any) - assert.Equal(t, float64(i), entry["index"], "results must stay ordered by original index regardless of chunk grouping") + assert.Equal(t, float64(i), entry["index"]) assert.Equal(t, "succeeded", entry["status"]) + assert.Equal(t, fmt.Sprintf("%d", 1000+i), entry["item"].(map[string]any)["full_database_id"]) } - assert.Equal(t, "1000", results[0].(map[string]any)["item"].(map[string]any)["full_database_id"]) - assert.Equal(t, "1001", results[1].(map[string]any)["item"].(map[string]any)["full_database_id"]) - assert.Equal(t, "1002", results[2].(map[string]any)["item"].(map[string]any)["full_database_id"]) + assert.Len(t, transport.mutationCalls, 1) } func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t *testing.T) { @@ -570,19 +600,10 @@ func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t * gqlClient := newTestGQLClient(transport) restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) - // 25 update items (2 chunks of 20 + 5) plus 1 clear item; only the first - // update chunk should ever be sent. - items := make([]any, 0, 26) + items := make([]any, 25) for i := range 25 { - items = append(items, map[string]any{ - "node_id": fmt.Sprintf("PVTI_item%d", i), - "updated_field": map[string]any{"name": "Notes", "value": "x"}, - }) + items[i] = map[string]any{"node_id": fmt.Sprintf("PVTI_item%d", i)} } - items = append(items, map[string]any{ - "node_id": "PVTI_clear", - "updated_field": map[string]any{"name": "Notes", "value": nil}, - }) deps := BaseDeps{Client: restClient, GQLClient: gqlClient} handler := toolDef.Handler(deps) @@ -591,6 +612,7 @@ func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t * "owner": "octo-org", "owner_type": "org", "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "x"}, "items": items, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -603,7 +625,7 @@ func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t * var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) assert.Equal(t, float64(0), response["succeeded"]) - assert.Equal(t, float64(26), response["unknown"]) + assert.Equal(t, float64(25), response["unknown"]) assert.Len(t, transport.mutationCalls, 1, "only the first (failing) chunk should have been sent") results := response["results"].([]any) @@ -628,10 +650,11 @@ func Test_ProjectsWrite_UpdateProjectItems_AllFailedSetsIsError(t *testing.T) { "owner": "octo-org", "owner_type": "org", "project_number": float64(1), + "updated_field": map[string]any{"name": "Notes", "value": "x"}, "items": []any{ map[string]any{}, - map[string]any{"node_id": "PVTI_missing_value", "updated_field": map[string]any{"name": "Notes"}}, - map[string]any{"node_id": "PVTI_bad_field", "updated_field": map[string]any{"id": float64(1), "name": "Notes", "value": "x"}}, + map[string]any{"node_id": ""}, + map[string]any{"item_id": float64(0)}, }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) diff --git a/pkg/github/projects_batch_resolve.go b/pkg/github/projects_batch_resolve.go index 36f9777b02..387a564bac 100644 --- a/pkg/github/projects_batch_resolve.go +++ b/pkg/github/projects_batch_resolve.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "math" - "strings" "sync" "time" @@ -16,104 +15,6 @@ import ( const batchItemLookupConcurrency = 5 -type projectFieldsCache struct { - loaded bool - loadErr error - fields []ResolvedField - byName map[string][]ResolvedField - byID map[string]ResolvedField -} - -func newProjectFieldsCache() *projectFieldsCache { - return &projectFieldsCache{} -} - -func (c *projectFieldsCache) ensureLoaded(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) error { - if c.loaded { - return c.loadErr - } - all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) - c.loaded = true - c.loadErr = err - if err != nil { - return err - } - c.fields = all - c.byName = make(map[string][]ResolvedField, len(all)) - c.byID = make(map[string]ResolvedField, len(all)) - for _, f := range all { - key := strings.ToLower(f.Name) - c.byName[key] = append(c.byName[key], f) - c.byID[f.ID] = f - } - return nil -} - -func (c *projectFieldsCache) resolveFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName string) (*ResolvedField, error) { - if fieldName == "" { - return nil, fmt.Errorf("field name must not be empty") - } - if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { - return nil, err - } - matches := c.byName[strings.ToLower(fieldName)] - switch len(matches) { - case 0: - return nil, ghErrors.NewStructuredResolutionError( - "field_not_found", - fieldName, - fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), - c.fieldCandidates(), - ) - case 1: - f := matches[0] - return &f, nil - default: - candidates := make([]any, 0, len(matches)) - for _, f := range matches { - candidates = append(candidates, map[string]any{ - "id": f.ID, - "data_type": f.DataType, - }) - } - return nil, ghErrors.NewStructuredResolutionError( - "field_ambiguous", - fieldName, - "multiple fields share this name; pass updated_field.id to disambiguate", - candidates, - ) - } -} - -func (c *projectFieldsCache) resolveFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) { - if err := c.ensureLoaded(ctx, gqlClient, owner, ownerType, projectNumber); err != nil { - return nil, err - } - idStr := fmt.Sprintf("%d", fieldID) - if f, ok := c.byID[idStr]; ok { - field := f - return &field, nil - } - return nil, ghErrors.NewStructuredResolutionError( - "field_not_found", - idStr, - fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", idStr, owner, projectNumber), - c.fieldCandidates(), - ) -} - -func (c *projectFieldsCache) fieldCandidates() []any { - candidates := make([]any, 0, len(c.fields)) - for _, f := range c.fields { - candidates = append(candidates, map[string]any{ - "id": f.ID, - "name": f.Name, - "data_type": f.DataType, - }) - } - return candidates -} - type batchItemRefKind int const ( @@ -134,13 +35,10 @@ type parsedBatchItem struct { issueRepo string issueNumber int - fieldSpec map[string]any - fieldKey string - rawValue any - err *batchItemError + err *batchItemError } -func parseBatchItemEntry(index int, raw any, sharedField map[string]any) parsedBatchItem { +func parseBatchItemEntry(index int, raw any) parsedBatchItem { p := parsedBatchItem{index: index} entry, ok := raw.(map[string]any) @@ -150,26 +48,10 @@ func parseBatchItemEntry(index int, raw any, sharedField map[string]any) parsedB } p.ref = itemRefEcho(entry) - effectiveField, fieldErr := effectiveUpdatedField(entry, sharedField) - if fieldErr != nil { - p.err = &batchItemError{Code: "invalid_updated_field", Message: fieldErr.Error()} - return p - } - p.fieldSpec = effectiveField - - fieldKey, keyErr := fieldSpecKey(effectiveField) - if keyErr != nil { - p.err = &batchItemError{Code: "invalid_updated_field", Message: keyErr.Error()} + 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 } - p.fieldKey = fieldKey - - rawValue, hasValue := effectiveField["value"] - if !hasValue { - p.err = &batchItemError{Code: "invalid_updated_field", Message: "updated_field.value is required"} - return p - } - p.rawValue = rawValue if refErr := p.parseItemRef(entry); refErr != nil { p.err = &batchItemError{Code: "invalid_item_ref", Message: refErr.Error()} @@ -235,20 +117,6 @@ func (p *parsedBatchItem) parseItemRef(entry map[string]any) error { return nil } -func effectiveUpdatedField(entry, shared map[string]any) (map[string]any, error) { - if raw, ok := entry["updated_field"]; ok { - m, isMap := raw.(map[string]any) - if !isMap || m == nil { - return nil, fmt.Errorf("updated_field must be an object") - } - return m, nil - } - if shared != nil { - return shared, nil - } - return nil, fmt.Errorf("updated_field is required either at the top level or on each item") -} - 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"} { @@ -300,65 +168,82 @@ func validatePositiveInt64(value any) (int64, error) { return n, nil } -func fieldSpecKey(spec map[string]any) (string, error) { - idField, hasID := spec["id"] - nameField, hasName := spec["name"] +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 "", fmt.Errorf("updated_field must set either id or name, not both") + return spec, fmt.Errorf("updated_field must set either id or name, not both") case !hasID && !hasName: - return "", fmt.Errorf("updated_field requires either id or name") + return spec, fmt.Errorf("updated_field requires either id or name") case hasID: id, err := validatePositiveInt64(idField) if err != nil { - return "", fmt.Errorf("updated_field.id: %w", err) + return spec, fmt.Errorf("updated_field.id: %w", err) } - return fmt.Sprintf("id:%d", id), nil + spec.id = id default: name, ok := nameField.(string) if !ok || name == "" { - return "", fmt.Errorf("updated_field.name must be a non-empty string") + return spec, fmt.Errorf("updated_field.name must be a non-empty string") } - return "name:" + strings.ToLower(name), nil + spec.name = name } + return spec, nil } -type fieldResolution struct { - field *ResolvedField - err error -} +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, "") + } -func resolveDistinctFields(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, items []parsedBatchItem) map[string]fieldResolution { - resolved := make(map[string]fieldResolution) - for _, it := range items { - if it.err != nil || it.fieldSpec == nil { - continue - } - key, keyErr := fieldSpecKey(it.fieldSpec) - if keyErr != nil { - continue - } - if _, done := resolved[key]; done { - continue + 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 } - resolved[key] = resolveFieldSpec(ctx, gqlClient, cache, owner, ownerType, projectNumber, it.fieldSpec) } - return resolved + 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 resolveFieldSpec(ctx context.Context, gqlClient *githubv4.Client, cache *projectFieldsCache, owner, ownerType string, projectNumber int, spec map[string]any) fieldResolution { - if idField, hasID := spec["id"]; hasID { - id, err := validatePositiveInt64(idField) - if err != nil { - return fieldResolution{err: fmt.Errorf("updated_field.id: %w", err)} - } - f, err := cache.resolveFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, id) - return fieldResolution{field: f, err: err} +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, + }) } - name, _ := spec["name"].(string) - f, err := cache.resolveFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, name) - return fieldResolution{field: f, err: err} + return candidates } func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) { diff --git a/pkg/github/projects_batch_resolve_test.go b/pkg/github/projects_batch_resolve_test.go index ef5833311b..89afeda218 100644 --- a/pkg/github/projects_batch_resolve_test.go +++ b/pkg/github/projects_batch_resolve_test.go @@ -2,7 +2,6 @@ package github import ( "encoding/json" - "errors" "math" "net/http" "testing" @@ -114,52 +113,18 @@ func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) { } func Test_ParseBatchItemEntry_InvalidShape(t *testing.T) { - p := parseBatchItemEntry(0, "not-an-object", nil) + p := parseBatchItemEntry(0, "not-an-object") require.NotNil(t, p.err) assert.Equal(t, "invalid_item", p.err.Code) } -func Test_ParseBatchItemEntry_UsesSharedFieldWhenNoPerItemOverride(t *testing.T) { - shared := map[string]any{"id": float64(101), "value": "In Progress"} - p := parseBatchItemEntry(0, map[string]any{"item_id": float64(1)}, shared) - require.Nil(t, p.err) - assert.Equal(t, shared, p.fieldSpec) -} - -func Test_ParseBatchItemEntry_PerItemOverrideWinsOverShared(t *testing.T) { - shared := map[string]any{"id": float64(101), "value": "In Progress"} - override := map[string]any{"id": float64(202), "value": "Done"} - p := parseBatchItemEntry(0, map[string]any{"item_id": float64(1), "updated_field": override}, shared) - require.Nil(t, p.err) - assert.Equal(t, override, p.fieldSpec) -} - -func Test_FieldSpecKey(t *testing.T) { - tests := []struct { - name string - spec map[string]any - want string - wantErr bool - }{ - {name: "by id", spec: map[string]any{"id": float64(101), "value": "x"}, want: "id:101"}, - {name: "by name", spec: map[string]any{"name": "Status", "value": "x"}, want: "name:status"}, - {name: "by name case-insensitive dedup", spec: map[string]any{"name": "STATUS", "value": "x"}, want: "name:status"}, - {name: "both id and name", spec: map[string]any{"id": float64(1), "name": "Status", "value": "x"}, wantErr: true}, - {name: "neither id nor name", spec: map[string]any{"value": "x"}, wantErr: true}, - {name: "zero id", spec: map[string]any{"id": float64(0), "value": "x"}, wantErr: true}, - {name: "negative id", spec: map[string]any{"id": float64(-1), "value": "x"}, wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := fieldSpecKey(tt.spec) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } +func Test_ParseBatchItemEntry_RejectsPerItemUpdatedField(t *testing.T) { + p := parseBatchItemEntry(0, map[string]any{ + "node_id": "PVTI_1", + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + }) + require.NotNil(t, p.err) + assert.Contains(t, p.err.Message, "use the top-level updated_field") } func Test_ConvertProjectFieldValue_Text(t *testing.T) { @@ -264,36 +229,37 @@ func Test_ConvertProjectFieldValue_UnsupportedDataType(t *testing.T) { assert.Contains(t, err.Error(), "update_project_item") } -func Test_ProjectFieldsCache_ResolveFieldByID_And_ByName(t *testing.T) { - cache := &projectFieldsCache{ - loaded: true, - fields: []ResolvedField{ - {ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}, - {ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}, - }, - byName: map[string][]ResolvedField{ - "status": {{ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}}, - "estimate": {{ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}}, - }, - byID: map[string]ResolvedField{ - "101": {ID: "101", NodeID: "PVTF_status", Name: "Status", DataType: "SINGLE_SELECT"}, - "202": {ID: "202", NodeID: "PVTF_estimate", Name: "Estimate", DataType: "NUMBER"}, - }, +func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) { + tests := []struct { + name string + spec batchFieldSpec + wantID string + }{ + {name: "numeric ID", spec: batchFieldSpec{id: 101}, wantID: "PVTF_status"}, + {name: "case-insensitive name", spec: batchFieldSpec{name: "priority"}, wantID: "PVTF_priority"}, } - byID, err := cache.resolveFieldByID(t.Context(), nil, "octo-org", "org", 1, 101) - require.NoError(t, err) - assert.Equal(t, "PVTF_status", byID.NodeID) - - byName, err := cache.resolveFieldByName(t.Context(), nil, "octo-org", "org", 1, "estimate") - require.NoError(t, err) - assert.Equal(t, "PVTF_estimate", byName.NodeID) - - _, err = cache.resolveFieldByID(t.Context(), nil, "octo-org", "org", 1, 999) - require.Error(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTF_status", 101, "Status", nil), + statusFieldNode("PVTF_priority", 202, "Priority", nil), + })), + ), + ) + + field, err := resolveBatchProjectField(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, tt.spec) + require.NoError(t, err) + assert.Equal(t, tt.wantID, field.NodeID) + }) + } } -func Test_ProjectFieldsCache_ResolveFieldByName_Ambiguous(t *testing.T) { +func Test_ResolveBatchProjectField_AmbiguousName(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( projectFieldsTestQuery{}, @@ -305,7 +271,14 @@ func Test_ProjectFieldsCache_ResolveFieldByName_Ambiguous(t *testing.T) { ), ) - _, err := newProjectFieldsCache().resolveFieldByName(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, "status") + _, err := resolveBatchProjectField( + t.Context(), + githubv4.NewClient(mocked), + "octo-org", + "org", + 7, + batchFieldSpec{name: "status"}, + ) require.Error(t, err) var response struct { @@ -318,27 +291,6 @@ func Test_ProjectFieldsCache_ResolveFieldByName_Ambiguous(t *testing.T) { assert.ElementsMatch(t, []any{"101", "202"}, []any{response.Candidates[0]["id"], response.Candidates[1]["id"]}) } -func Test_ProjectFieldsCache_LoadFailureIsCached(t *testing.T) { - transport := &errorGraphQLTransport{err: errors.New("unavailable")} - gqlClient := newTestGQLClient(transport) - items := []parsedBatchItem{ - parseBatchItemEntry(0, map[string]any{ - "node_id": "PVTI_1", - "updated_field": map[string]any{"name": "Notes", "value": "x"}, - }, nil), - parseBatchItemEntry(1, map[string]any{ - "node_id": "PVTI_2", - "updated_field": map[string]any{"name": "Estimate", "value": float64(1)}, - }, nil), - } - - resolved := resolveDistinctFields(t.Context(), gqlClient, newProjectFieldsCache(), "octo-org", "org", 1, items) - - require.Error(t, resolved["name:notes"].err) - require.Error(t, resolved["name:estimate"].err) - assert.Equal(t, 1, transport.calls) -} - func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 519e80e494..c6be1e2131 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -888,7 +888,7 @@ func Test_ProjectsWrite(t *testing.T) { assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) } -func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) { +func Test_ProjectsWrite_UpdateProjectItemsSchema(t *testing.T) { inputSchema := ProjectsWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) itemSchema := inputSchema.Properties["items"].Items @@ -902,9 +902,9 @@ func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) { {"item_owner", "item_repo", "issue_number"}, } expectedProperties := [][]string{ - {"node_id", "updated_field"}, - {"item_id", "updated_field"}, - {"item_owner", "item_repo", "issue_number", "updated_field"}, + {"node_id"}, + {"item_id"}, + {"item_owner", "item_repo", "issue_number"}, } for i, variant := range itemSchema.OneOf { properties := make([]string, 0, len(variant.Properties)) @@ -914,7 +914,6 @@ func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) { assert.Equal(t, "object", variant.Type) assert.ElementsMatch(t, expectedRequired[i], variant.Required) assert.ElementsMatch(t, expectedProperties[i], properties) - assert.Contains(t, variant.Properties, "updated_field") for _, property := range variant.Properties { assert.NotEmpty(t, property.Type) assert.NotEmpty(t, property.Description) @@ -922,6 +921,28 @@ func Test_ProjectsWrite_UpdateProjectItemsItemSchema(t *testing.T) { 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) { From 2e39176363a13c4af4f3fe02cc6ea4c32d245345 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 14:41:12 -0400 Subject: [PATCH 08/11] Consolidate batch project files Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- pkg/github/projects_batch.go | 436 +++++++++++++++++ pkg/github/projects_batch_resolve.go | 447 ------------------ pkg/github/projects_batch_resolve_test.go | 322 ------------- ...tch_e2e_test.go => projects_batch_test.go} | 310 ++++++++++++ 4 files changed, 746 insertions(+), 769 deletions(-) delete mode 100644 pkg/github/projects_batch_resolve.go delete mode 100644 pkg/github/projects_batch_resolve_test.go rename pkg/github/{projects_batch_e2e_test.go => projects_batch_test.go} (74%) diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 08e1244505..8d09090719 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "math" + "sync" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" @@ -318,3 +321,436 @@ func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, pars } } } + +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 { + out := make(map[issueRefKey]itemLookupResult) + for _, it := range items { + if it.err != nil || it.refKind != batchRefIssue { + continue + } + key := issueRefKey{owner: it.issueOwner, repo: it.issueRepo, number: it.issueNumber} + if _, done := out[key]; done { + continue + } + nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, it.issueOwner, it.issueRepo, it.issueNumber) + out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err} + } + 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: "invalid_argument", + Message: err.Error(), + } +} diff --git a/pkg/github/projects_batch_resolve.go b/pkg/github/projects_batch_resolve.go deleted file mode 100644 index 387a564bac..0000000000 --- a/pkg/github/projects_batch_resolve.go +++ /dev/null @@ -1,447 +0,0 @@ -package github - -import ( - "context" - "errors" - "fmt" - "math" - "sync" - "time" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/google/go-github/v89/github" - "github.com/shurcooL/githubv4" -) - -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 { - out := make(map[issueRefKey]itemLookupResult) - for _, it := range items { - if it.err != nil || it.refKind != batchRefIssue { - continue - } - key := issueRefKey{owner: it.issueOwner, repo: it.issueRepo, number: it.issueNumber} - if _, done := out[key]; done { - continue - } - nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, it.issueOwner, it.issueRepo, it.issueNumber) - out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err} - } - 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: "invalid_argument", - Message: err.Error(), - } -} diff --git a/pkg/github/projects_batch_resolve_test.go b/pkg/github/projects_batch_resolve_test.go deleted file mode 100644 index 89afeda218..0000000000 --- a/pkg/github/projects_batch_resolve_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package github - -import ( - "encoding/json" - "math" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/shurcooL/githubv4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ParseItemRef_ExactlyOneFormRequired(t *testing.T) { - tests := []struct { - name string - entry map[string]any - wantErr string - }{ - { - name: "none provided", - entry: map[string]any{}, - wantErr: "exactly one of", - }, - { - name: "node_id and item_id both provided", - entry: map[string]any{"node_id": "PVTI_x", "item_id": float64(1)}, - wantErr: "not more than one", - }, - { - name: "item_id and issue ref both provided", - entry: map[string]any{"item_id": float64(1), "item_owner": "o", "item_repo": "r", "issue_number": float64(1)}, - wantErr: "not more than one", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(tt.entry) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - }) - } -} - -func Test_ParseItemRef_NodeIDBypassesLookup(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(map[string]any{"node_id": "PVTI_abc123"}) - require.NoError(t, err) - assert.Equal(t, batchRefNodeID, p.refKind) - assert.Equal(t, "PVTI_abc123", p.nodeID) -} - -func Test_ParseItemRef_ItemID(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(map[string]any{"item_id": float64(42)}) - require.NoError(t, err) - assert.Equal(t, batchRefItemID, p.refKind) - assert.Equal(t, int64(42), p.itemID) -} - -func Test_ParseItemRef_IssueRef(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)}) - require.NoError(t, err) - assert.Equal(t, batchRefIssue, p.refKind) - assert.Equal(t, "github", p.issueOwner) - assert.Equal(t, "planning-tracking", p.issueRepo) - assert.Equal(t, 123, p.issueNumber) -} - -func Test_ParseItemRef_InvalidNumericReferences(t *testing.T) { - issueRef := func(value any) map[string]any { - return map[string]any{ - "item_owner": "github", - "item_repo": "planning-tracking", - "issue_number": value, - } - } - tests := []struct { - name string - entry map[string]any - }{ - {name: "zero item ID", entry: map[string]any{"item_id": float64(0)}}, - {name: "negative item ID", entry: map[string]any{"item_id": float64(-1)}}, - {name: "fractional item ID", entry: map[string]any{"item_id": float64(1.5)}}, - {name: "NaN item ID", entry: map[string]any{"item_id": math.NaN()}}, - {name: "infinite item ID", entry: map[string]any{"item_id": math.Inf(1)}}, - {name: "overflowing item ID", entry: map[string]any{"item_id": math.MaxFloat64}}, - {name: "zero issue number", entry: issueRef(float64(0))}, - {name: "negative issue number", entry: issueRef(float64(-1))}, - {name: "fractional issue number", entry: issueRef(float64(1.5))}, - {name: "overflowing issue number", entry: issueRef(float64(math.MaxInt32) + 1)}, - {name: "NaN issue number", entry: issueRef(math.NaN())}, - {name: "infinite issue number", entry: issueRef(math.Inf(1))}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(tt.entry) - require.Error(t, err) - }) - } -} - -func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) { - p := parsedBatchItem{} - err := p.parseItemRef(map[string]any{"item_owner": "github"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "must all be provided together") -} - -func Test_ParseBatchItemEntry_InvalidShape(t *testing.T) { - p := parseBatchItemEntry(0, "not-an-object") - require.NotNil(t, p.err) - assert.Equal(t, "invalid_item", p.err.Code) -} - -func Test_ParseBatchItemEntry_RejectsPerItemUpdatedField(t *testing.T) { - p := parseBatchItemEntry(0, map[string]any{ - "node_id": "PVTI_1", - "updated_field": map[string]any{"name": "Notes", "value": "x"}, - }) - require.NotNil(t, p.err) - assert.Contains(t, p.err.Message, "use the top-level updated_field") -} - -func Test_ConvertProjectFieldValue_Text(t *testing.T) { - field := &ResolvedField{Name: "Notes", DataType: "TEXT"} - v, err := convertProjectFieldValue(field, "hello") - require.NoError(t, err) - require.NotNil(t, v.Text) - assert.Equal(t, "hello", string(*v.Text)) -} - -func Test_ConvertProjectFieldValue_Text_WrongType(t *testing.T) { - field := &ResolvedField{Name: "Notes", DataType: "TEXT"} - _, err := convertProjectFieldValue(field, float64(1)) - require.Error(t, err) -} - -func Test_ConvertProjectFieldValue_Number(t *testing.T) { - field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} - v, err := convertProjectFieldValue(field, float64(8)) - require.NoError(t, err) - require.NotNil(t, v.Number) - assert.InDelta(t, 8.0, float64(*v.Number), 0.0001) -} - -func Test_ConvertProjectFieldValue_Number_NonFinite(t *testing.T) { - field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} - for _, value := range []float64{math.NaN(), math.Inf(-1), math.Inf(1)} { - _, err := convertProjectFieldValue(field, value) - require.Error(t, err) - } -} - -func Test_ConvertProjectFieldValue_Date(t *testing.T) { - field := &ResolvedField{Name: "Due", DataType: "DATE"} - v, err := convertProjectFieldValue(field, "2024-01-15") - require.NoError(t, err) - require.NotNil(t, v.Date) - assert.Equal(t, 2024, v.Date.Year()) - assert.Equal(t, 1, int(v.Date.Month())) - assert.Equal(t, 15, v.Date.Day()) -} - -func Test_ConvertProjectFieldValue_Date_BadFormat(t *testing.T) { - field := &ResolvedField{Name: "Due", DataType: "DATE"} - _, err := convertProjectFieldValue(field, "01/15/2024") - require.Error(t, err) -} - -func Test_ConvertProjectFieldValue_SingleSelect_ByName(t *testing.T) { - field := &ResolvedField{ - Name: "Status", - DataType: "SINGLE_SELECT", - Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, - } - v, err := convertProjectFieldValue(field, "In Progress") - require.NoError(t, err) - require.NotNil(t, v.SingleSelectOptionID) - assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) -} - -func Test_ConvertProjectFieldValue_SingleSelect_ByOptionID(t *testing.T) { - field := &ResolvedField{ - Name: "Status", - DataType: "SINGLE_SELECT", - Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, - } - v, err := convertProjectFieldValue(field, "OPT_1") - require.NoError(t, err) - require.NotNil(t, v.SingleSelectOptionID) - assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) -} - -func Test_ConvertProjectFieldValue_SingleSelect_Unknown(t *testing.T) { - field := &ResolvedField{ - Name: "Status", - DataType: "SINGLE_SELECT", - Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, - } - _, err := convertProjectFieldValue(field, "Nonexistent") - require.Error(t, err) -} - -func Test_ConvertProjectFieldValue_Iteration(t *testing.T) { - field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} - v, err := convertProjectFieldValue(field, "abc123==") - require.NoError(t, err) - require.NotNil(t, v.IterationID) - assert.Equal(t, "abc123==", string(*v.IterationID)) -} - -func Test_ConvertProjectFieldValue_Iteration_EmptyIsError(t *testing.T) { - field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} - _, err := convertProjectFieldValue(field, "") - require.Error(t, err) -} - -func Test_ConvertProjectFieldValue_UnsupportedDataType(t *testing.T) { - field := &ResolvedField{Name: "Assignees", DataType: "ASSIGNEES"} - _, err := convertProjectFieldValue(field, "someone") - require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported data type") - assert.Contains(t, err.Error(), "update_project_item") -} - -func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) { - tests := []struct { - name string - spec batchFieldSpec - wantID string - }{ - {name: "numeric ID", spec: batchFieldSpec{id: 101}, wantID: "PVTF_status"}, - {name: "case-insensitive name", spec: batchFieldSpec{name: "priority"}, wantID: "PVTF_priority"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mocked := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 7), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTF_status", 101, "Status", nil), - statusFieldNode("PVTF_priority", 202, "Priority", nil), - })), - ), - ) - - field, err := resolveBatchProjectField(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, tt.spec) - require.NoError(t, err) - assert.Equal(t, tt.wantID, field.NodeID) - }) - } -} - -func Test_ResolveBatchProjectField_AmbiguousName(t *testing.T) { - mocked := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 7), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTSSF_status1", 101, "Status", nil), - statusFieldNode("PVTSSF_status2", 202, "Status", nil), - })), - ), - ) - - _, err := resolveBatchProjectField( - t.Context(), - githubv4.NewClient(mocked), - "octo-org", - "org", - 7, - batchFieldSpec{name: "status"}, - ) - require.Error(t, err) - - var response struct { - Error string `json:"error"` - Candidates []map[string]any `json:"candidates"` - } - require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) - assert.Equal(t, "field_ambiguous", response.Error) - require.Len(t, response.Candidates, 2) - assert.ElementsMatch(t, []any{"101", "202"}, []any{response.Candidates[0]["id"], response.Candidates[1]["id"]}) -} - -func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing.T) { - tests := []struct { - name string - ownerType string - endpoint string - }{ - {name: "organization", ownerType: "org", endpoint: GetOrgsProjectsV2ItemsByProjectByItemID}, - {name: "user", ownerType: "user", endpoint: GetUsersProjectsV2ItemsByUsernameByProjectByItemID}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - calls := 0 - client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - tt.endpoint: func(w http.ResponseWriter, _ *http.Request) { - calls++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`)) - }, - })) - - resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001}) - - require.NoError(t, resolved[1001].err) - assert.Equal(t, "PVTI_item1001", resolved[1001].nodeID) - assert.Equal(t, 1, calls) - }) - } -} diff --git a/pkg/github/projects_batch_e2e_test.go b/pkg/github/projects_batch_test.go similarity index 74% rename from pkg/github/projects_batch_e2e_test.go rename to pkg/github/projects_batch_test.go index af27932d63..85ead2d02e 100644 --- a/pkg/github/projects_batch_e2e_test.go +++ b/pkg/github/projects_batch_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "strings" "sync/atomic" @@ -766,3 +767,312 @@ func Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring(t *testing.T) require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) assert.Equal(t, float64(1), response["succeeded"]) } + +func Test_ParseItemRef_ExactlyOneFormRequired(t *testing.T) { + tests := []struct { + name string + entry map[string]any + wantErr string + }{ + { + name: "none provided", + entry: map[string]any{}, + wantErr: "exactly one of", + }, + { + name: "node_id and item_id both provided", + entry: map[string]any{"node_id": "PVTI_x", "item_id": float64(1)}, + wantErr: "not more than one", + }, + { + name: "item_id and issue ref both provided", + entry: map[string]any{"item_id": float64(1), "item_owner": "o", "item_repo": "r", "issue_number": float64(1)}, + wantErr: "not more than one", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(tt.entry) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_ParseItemRef_NodeIDBypassesLookup(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"node_id": "PVTI_abc123"}) + require.NoError(t, err) + assert.Equal(t, batchRefNodeID, p.refKind) + assert.Equal(t, "PVTI_abc123", p.nodeID) +} + +func Test_ParseItemRef_ItemID(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_id": float64(42)}) + require.NoError(t, err) + assert.Equal(t, batchRefItemID, p.refKind) + assert.Equal(t, int64(42), p.itemID) +} + +func Test_ParseItemRef_IssueRef(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_owner": "github", "item_repo": "planning-tracking", "issue_number": float64(123)}) + require.NoError(t, err) + assert.Equal(t, batchRefIssue, p.refKind) + assert.Equal(t, "github", p.issueOwner) + assert.Equal(t, "planning-tracking", p.issueRepo) + assert.Equal(t, 123, p.issueNumber) +} + +func Test_ParseItemRef_InvalidNumericReferences(t *testing.T) { + issueRef := func(value any) map[string]any { + return map[string]any{ + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": value, + } + } + tests := []struct { + name string + entry map[string]any + }{ + {name: "zero item ID", entry: map[string]any{"item_id": float64(0)}}, + {name: "negative item ID", entry: map[string]any{"item_id": float64(-1)}}, + {name: "fractional item ID", entry: map[string]any{"item_id": float64(1.5)}}, + {name: "NaN item ID", entry: map[string]any{"item_id": math.NaN()}}, + {name: "infinite item ID", entry: map[string]any{"item_id": math.Inf(1)}}, + {name: "overflowing item ID", entry: map[string]any{"item_id": math.MaxFloat64}}, + {name: "zero issue number", entry: issueRef(float64(0))}, + {name: "negative issue number", entry: issueRef(float64(-1))}, + {name: "fractional issue number", entry: issueRef(float64(1.5))}, + {name: "overflowing issue number", entry: issueRef(float64(math.MaxInt32) + 1)}, + {name: "NaN issue number", entry: issueRef(math.NaN())}, + {name: "infinite issue number", entry: issueRef(math.Inf(1))}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(tt.entry) + require.Error(t, err) + }) + } +} + +func Test_ParseItemRef_PartialIssueRefIsError(t *testing.T) { + p := parsedBatchItem{} + err := p.parseItemRef(map[string]any{"item_owner": "github"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must all be provided together") +} + +func Test_ParseBatchItemEntry_InvalidShape(t *testing.T) { + p := parseBatchItemEntry(0, "not-an-object") + require.NotNil(t, p.err) + assert.Equal(t, "invalid_item", p.err.Code) +} + +func Test_ParseBatchItemEntry_RejectsPerItemUpdatedField(t *testing.T) { + p := parseBatchItemEntry(0, map[string]any{ + "node_id": "PVTI_1", + "updated_field": map[string]any{"name": "Notes", "value": "x"}, + }) + require.NotNil(t, p.err) + assert.Contains(t, p.err.Message, "use the top-level updated_field") +} + +func Test_ConvertProjectFieldValue_Text(t *testing.T) { + field := &ResolvedField{Name: "Notes", DataType: "TEXT"} + v, err := convertProjectFieldValue(field, "hello") + require.NoError(t, err) + require.NotNil(t, v.Text) + assert.Equal(t, "hello", string(*v.Text)) +} + +func Test_ConvertProjectFieldValue_Text_WrongType(t *testing.T) { + field := &ResolvedField{Name: "Notes", DataType: "TEXT"} + _, err := convertProjectFieldValue(field, float64(1)) + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_Number(t *testing.T) { + field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} + v, err := convertProjectFieldValue(field, float64(8)) + require.NoError(t, err) + require.NotNil(t, v.Number) + assert.InDelta(t, 8.0, float64(*v.Number), 0.0001) +} + +func Test_ConvertProjectFieldValue_Number_NonFinite(t *testing.T) { + field := &ResolvedField{Name: "Estimate", DataType: "NUMBER"} + for _, value := range []float64{math.NaN(), math.Inf(-1), math.Inf(1)} { + _, err := convertProjectFieldValue(field, value) + require.Error(t, err) + } +} + +func Test_ConvertProjectFieldValue_Date(t *testing.T) { + field := &ResolvedField{Name: "Due", DataType: "DATE"} + v, err := convertProjectFieldValue(field, "2024-01-15") + require.NoError(t, err) + require.NotNil(t, v.Date) + assert.Equal(t, 2024, v.Date.Year()) + assert.Equal(t, 1, int(v.Date.Month())) + assert.Equal(t, 15, v.Date.Day()) +} + +func Test_ConvertProjectFieldValue_Date_BadFormat(t *testing.T) { + field := &ResolvedField{Name: "Due", DataType: "DATE"} + _, err := convertProjectFieldValue(field, "01/15/2024") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_SingleSelect_ByName(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + v, err := convertProjectFieldValue(field, "In Progress") + require.NoError(t, err) + require.NotNil(t, v.SingleSelectOptionID) + assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) +} + +func Test_ConvertProjectFieldValue_SingleSelect_ByOptionID(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + v, err := convertProjectFieldValue(field, "OPT_1") + require.NoError(t, err) + require.NotNil(t, v.SingleSelectOptionID) + assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) +} + +func Test_ConvertProjectFieldValue_SingleSelect_Unknown(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_1", Name: "In Progress"}}, + } + _, err := convertProjectFieldValue(field, "Nonexistent") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_Iteration(t *testing.T) { + field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} + v, err := convertProjectFieldValue(field, "abc123==") + require.NoError(t, err) + require.NotNil(t, v.IterationID) + assert.Equal(t, "abc123==", string(*v.IterationID)) +} + +func Test_ConvertProjectFieldValue_Iteration_EmptyIsError(t *testing.T) { + field := &ResolvedField{Name: "Sprint", DataType: "ITERATION"} + _, err := convertProjectFieldValue(field, "") + require.Error(t, err) +} + +func Test_ConvertProjectFieldValue_UnsupportedDataType(t *testing.T) { + field := &ResolvedField{Name: "Assignees", DataType: "ASSIGNEES"} + _, err := convertProjectFieldValue(field, "someone") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported data type") + assert.Contains(t, err.Error(), "update_project_item") +} + +func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) { + tests := []struct { + name string + spec batchFieldSpec + wantID string + }{ + {name: "numeric ID", spec: batchFieldSpec{id: 101}, wantID: "PVTF_status"}, + {name: "case-insensitive name", spec: batchFieldSpec{name: "priority"}, wantID: "PVTF_priority"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTF_status", 101, "Status", nil), + statusFieldNode("PVTF_priority", 202, "Priority", nil), + })), + ), + ) + + field, err := resolveBatchProjectField(t.Context(), githubv4.NewClient(mocked), "octo-org", "org", 7, tt.spec) + require.NoError(t, err) + assert.Equal(t, tt.wantID, field.NodeID) + }) + } +} + +func Test_ResolveBatchProjectField_AmbiguousName(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status1", 101, "Status", nil), + statusFieldNode("PVTSSF_status2", 202, "Status", nil), + })), + ), + ) + + _, err := resolveBatchProjectField( + t.Context(), + githubv4.NewClient(mocked), + "octo-org", + "org", + 7, + batchFieldSpec{name: "status"}, + ) + require.Error(t, err) + + var response struct { + Error string `json:"error"` + Candidates []map[string]any `json:"candidates"` + } + require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) + assert.Equal(t, "field_ambiguous", response.Error) + require.Len(t, response.Candidates, 2) + assert.ElementsMatch(t, []any{"101", "202"}, []any{response.Candidates[0]["id"], response.Candidates[1]["id"]}) +} + +func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing.T) { + tests := []struct { + name string + ownerType string + endpoint string + }{ + {name: "organization", ownerType: "org", endpoint: GetOrgsProjectsV2ItemsByProjectByItemID}, + {name: "user", ownerType: "user", endpoint: GetUsersProjectsV2ItemsByUsernameByProjectByItemID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.endpoint: func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`)) + }, + })) + + resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001}) + + require.NoError(t, resolved[1001].err) + assert.Equal(t, "PVTI_item1001", resolved[1001].nodeID) + assert.Equal(t, 1, calls) + }) + } +} From 267146b86e31889620f1b45446de4d86c04b6c21 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 15:07:19 -0400 Subject: [PATCH 09/11] Improve batch project write discoverability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 4 ++-- pkg/github/projects.go | 4 ++-- pkg/github/projects_test.go | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 63f14abdb5..3cae520907 100644 --- a/README.md +++ b/README.md @@ -1119,7 +1119,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'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 items per call. (object[], 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: 100 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) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 9f1b477c69..756b75e752 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -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": { @@ -41,7 +41,7 @@ "type": "string" }, "items": { - "description": "The items to update with the top-level 'updated_field'. Required for 'update_project_items'. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 items per call.", + "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: 100 items per call.", "items": { "oneOf": [ { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index bd266c7fe1..15c1c3023e 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -575,7 +575,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { 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, @@ -642,7 +642,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { "updated_field": projectUpdatedFieldSchema(), "items": { Type: "array", - Description: "The items to update with the top-level 'updated_field'. Required for 'update_project_items'. 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.", + 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": { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index c6be1e2131..92de4a5d5f 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -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") @@ -890,6 +890,7 @@ func Test_ProjectsWrite(t *testing.T) { 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) From ab0cd28586a0ed8c73c7aa3ee1fc7f5399e1b0aa Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 15:09:00 -0400 Subject: [PATCH 10/11] Carry batch refs with resolved items Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- pkg/github/projects_batch.go | 53 +++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 8d09090719..3c77d250c7 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -50,10 +50,19 @@ type batchItemError struct { 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 { @@ -158,10 +167,16 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie } seenTargets[nodeID] = i - work = append(work, resolvedBatchItem{index: i, nodeID: nodeID, fullDatabaseID: fullDatabaseID}) + work = append(work, resolvedBatchItem{index: i, ref: p.ref, nodeID: nodeID, fullDatabaseID: fullDatabaseID}) } - executeBatchWrites(ctx, gqlClient, kind, projectID, githubv4.ID(field.NodeID), value, work, results, parsed) + executeBatchWrites(ctx, batchWriteOperation{ + gqlClient: gqlClient, + kind: kind, + projectID: projectID, + fieldID: githubv4.ID(field.NodeID), + value: value, + }, work, results) return newUpdateProjectItemsResult(results) } @@ -230,10 +245,10 @@ func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupR // 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, gqlClient *githubv4.Client, kind batchMutationKind, projectID, fieldID githubv4.ID, value githubv4.ProjectV2FieldValue, items []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem) { +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, parsed, ctx.Err()) + markChunkUnknown(items[start:], results, ctx.Err()) return } @@ -242,23 +257,23 @@ func executeBatchWrites(ctx context.Context, gqlClient *githubv4.Client, kind ba inputs := make([]githubv4.Input, len(chunk)) for i, item := range chunk { - if kind == batchMutationClear { + if operation.kind == batchMutationClear { inputs[i] = githubv4.ClearProjectV2ItemFieldValueInput{ - ProjectID: projectID, + ProjectID: operation.projectID, ItemID: githubv4.ID(item.nodeID), - FieldID: fieldID, + FieldID: operation.fieldID, } } else { inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{ - ProjectID: projectID, + ProjectID: operation.projectID, ItemID: githubv4.ID(item.nodeID), - FieldID: fieldID, - Value: value, + FieldID: operation.fieldID, + Value: operation.value, } } } - outcomes, mutateErr := executeAliasedMutation(ctx, gqlClient, kind, inputs) + outcomes, mutateErr := executeAliasedMutation(ctx, operation.gqlClient, operation.kind, inputs) populated := 0 for i, oc := range outcomes { @@ -267,7 +282,7 @@ func executeBatchWrites(ctx context.Context, gqlClient *githubv4.Client, kind ba results[chunk[i].index] = batchItemResult{ Index: chunk[i].index, Status: batchItemSucceeded, - Ref: parsed[chunk[i].index].ref, + Ref: chunk[i].ref, Item: &batchItemIdentity{ NodeID: oc.NodeID, FullDatabaseID: oc.FullDatabaseID, @@ -278,23 +293,23 @@ func executeBatchWrites(ctx context.Context, gqlClient *githubv4.Client, kind ba } if isGraphQLResponseError(mutateErr) { - markUnpopulatedUnknown(chunk, outcomes, results, parsed, mutateErr) + markUnpopulatedUnknown(chunk, outcomes, results, mutateErr) continue } if mutateErr != nil { - markChunkUnknown(items[start:], results, parsed, mutateErr) + markChunkUnknown(items[start:], results, mutateErr) return } if populated != len(chunk) { - markChunkUnknown(items[start:], results, parsed, fmt.Errorf("mutation response did not include every item")) + markChunkUnknown(items[start:], results, fmt.Errorf("mutation response did not include every item")) return } } } -func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasOutcome, results []batchItemResult, parsed []parsedBatchItem, err error) { +func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasOutcome, results []batchItemResult, err error) { for i, oc := range outcomes { if oc.Populated { continue @@ -302,13 +317,13 @@ func markUnpopulatedUnknown(chunk []resolvedBatchItem, outcomes []mutationAliasO results[chunk[i].index] = batchItemResult{ Index: chunk[i].index, Status: batchItemUnknown, - Ref: parsed[chunk[i].index].ref, + Ref: chunk[i].ref, Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()}, } } } -func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, parsed []parsedBatchItem, err error) { +func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, err error) { for _, item := range chunk { if results[item.index].Status == batchItemSucceeded { continue @@ -316,7 +331,7 @@ func markChunkUnknown(chunk []resolvedBatchItem, results []batchItemResult, pars results[item.index] = batchItemResult{ Index: item.index, Status: batchItemUnknown, - Ref: parsed[item.index].ref, + Ref: item.ref, Error: &batchItemError{Code: "mutation_unconfirmed", Message: err.Error()}, } } From 03efb11e9630ddfb5a10755bfc26e99582a25f5a Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 16:03:33 -0400 Subject: [PATCH 11/11] Keep batch write tests with orchestration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 --- pkg/github/projects_batch_test.go | 207 ++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 85ead2d02e..f17b4871f5 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "math" "net/http" "strings" @@ -57,6 +58,55 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git ) } +// mutationAwareTransport routes GraphQL requests to a fixed query-matcher +// transport (e.g. githubv4mock.NewMockedHTTPClient's Transport) for ordinary +// queries/lookups, and to a sequenced, call-counted responder for mutation +// requests, so end-to-end tests can assert on aliased-mutation call counts and +// per-call variables without needing to hand-construct the exact minified +// mutation query text that reflect.StructOf produces. +type mutationAwareTransport struct { + t *testing.T + queries http.RoundTripper + mutationRespond func(callIndex int, req capturedGraphQLRequest) (status int, body string) + queryCalls []capturedGraphQLRequest + mutationCalls []capturedGraphQLRequest +} + +func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + + var parsed struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, err + } + + if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") { + m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}) + req.Body = io.NopCloser(strings.NewReader(string(raw))) + return m.queries.RoundTrip(req) + } + + captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables} + idx := len(m.mutationCalls) + m.mutationCalls = append(m.mutationCalls, captured) + if m.mutationRespond == nil { + m.t.Fatalf("unexpected mutation call #%d (query: %s)", idx, parsed.Query) + } + status, body := m.mutationRespond(idx, captured) + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} + func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { tooMany := make([]any, maxProjectItemsPerBatch+1) validItem := map[string]any{"node_id": "PVTI_item1"} @@ -1076,3 +1126,160 @@ func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing }) } } + +func Test_ExecuteBatchWrites_AllAliasGraphQLErrorContinues(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationErrorResponse(t, map[string]any{}, "all aliases failed") + }, + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"}, + }) + }, + }, + } + items, results := batchItemsOfSize(21) + + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) + + assert.Len(t, transport.calls, 2) + for i := range 20 { + assert.Equal(t, batchItemUnknown, results[i].Status) + } + assert.Equal(t, batchItemSucceeded, results[20].Status) +} + +func Test_ExecuteBatchWrites_PartialGraphQLErrorPreservesSuccess(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationErrorResponse(t, map[string]any{ + "item0": map[string]any{ + "projectV2Item": map[string]any{"id": "PVTI_item0", "fullDatabaseId": "1000"}, + }, + "item1": nil, + }, "item1 failed") + }, + }, + } + items, results := batchItemsOfSize(2) + + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) + + assert.Equal(t, batchItemSucceeded, results[0].Status) + assert.Equal(t, items[0].ref, results[0].Ref) + assert.Equal(t, batchItemUnknown, results[1].Status) + assert.Equal(t, items[1].ref, results[1].Ref) +} + +func Test_ExecuteBatchWrites_AmbiguousSuccessResponseAborts(t *testing.T) { + tests := []struct { + name string + body string + confirmedSuccesses int + }{ + { + name: "null data", + body: `{"data":null}`, + }, + { + name: "missing data", + body: `{}`, + }, + { + name: "partial data without errors", + body: mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, + }), + confirmedSuccesses: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, tt.body + }, + func(_ capturedGraphQLRequest) (int, string) { + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ + 0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"}, + }) + }, + }, + } + items, results := batchItemsOfSize(21) + + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) + + assert.Len(t, transport.calls, 1) + for i, result := range results { + if i < tt.confirmedSuccesses { + assert.Equal(t, batchItemSucceeded, result.Status) + continue + } + assert.Equal(t, batchItemUnknown, result.Status) + } + }) + } +} + +func Test_ExecuteBatchWrites_TransportTimeoutAborts(t *testing.T) { + transport := &errorGraphQLTransport{err: context.DeadlineExceeded} + items, results := batchItemsOfSize(21) + + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) + + assert.Equal(t, 1, transport.calls) + for _, result := range results { + assert.Equal(t, batchItemUnknown, result.Status) + } +} + +func Test_ExecuteBatchWrites_CanceledContextSkipsWrites(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + transport := &sequencedGraphQLTransport{t: t} + items, results := batchItemsOfSize(21) + + executeTestBatchWrites(ctx, newTestGQLClient(transport), items, results) + + assert.Empty(t, transport.calls) + for _, result := range results { + assert.Equal(t, batchItemUnknown, result.Status) + } +} + +func executeTestBatchWrites(ctx context.Context, gqlClient *githubv4.Client, items []resolvedBatchItem, results []batchItemResult) { + executeBatchWrites( + ctx, + batchWriteOperation{ + gqlClient: gqlClient, + kind: batchMutationUpdate, + projectID: githubv4.ID("PVT_project"), + fieldID: githubv4.ID("PVTF_field"), + value: githubv4.ProjectV2FieldValue{Text: githubv4.NewString("value")}, + }, + items, + results, + ) +} + +func batchItemsOfSize(n int) ([]resolvedBatchItem, []batchItemResult) { + items := make([]resolvedBatchItem, n) + for i := range n { + nodeID := fmt.Sprintf("PVTI_item%d", i) + items[i] = resolvedBatchItem{ + index: i, + ref: map[string]any{"node_id": nodeID}, + nodeID: nodeID, + } + } + return items, make([]batchItemResult, n) +}