diff --git a/README.md b/README.md index 614404c0a..98c21e160 100644 --- a/README.md +++ b/README.md @@ -1034,7 +1034,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `read:project` - **Accepted OAuth Scopes**: `project`, `read:project` - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) - - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) + - `field_names`: Specific list of field names to include in the response when getting a project item (e.g. ["Status", "Priority"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Only used for 'get_project_item' method. (string[], optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - `method`: The method to execute (string, required) - `owner`: The owner (user or organization login). The name is not case sensitive. (string, optional) @@ -1047,7 +1048,8 @@ The following sets of tools are available: - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) + - `field_names`: Field names to include when listing project items (e.g. ["Status", "Priority"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Only used for 'list_project_items' method. (string[], optional) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Only used for 'list_project_items' method. (string[], optional) - `method`: The action to perform (string, required) - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) @@ -1059,10 +1061,10 @@ The following sets of tools are available: - **Required OAuth Scopes**: `project` - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) - `field_name`: The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method. (string, optional) - - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) - - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) - - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) + - `issue_number`: The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo). (number, optional) + - `item_id`: The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue. (number, optional) + - `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) - `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) @@ -1075,7 +1077,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 consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, 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, the value may be the option name (resolved server-side) or the option ID. Set value to null to clear the field. (object, optional) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index a1b35d697..efcf05b21 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,6 +2,7 @@ package errors import ( "context" + "encoding/json" stderrors "errors" "fmt" "net/http" @@ -218,3 +219,48 @@ func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp * err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) return NewGitHubAPIErrorResponse(ctx, message, resp, err) } + +// StructuredResolutionError is a machine-readable error returned by name-resolution +// helpers (e.g. resolving a project field or single-select option by name). Agents +// can parse the JSON body to self-correct without re-prompting. +// +// Kind values: +// - "field_not_found" — no project field matches the supplied name +// - "field_ambiguous" — more than one project field shares the supplied name +// - "option_not_found" — no option on the resolved single-select field matches +// - "option_ambiguous" — duplicate option names on the resolved field +// - "item_not_in_project" — the issue/PR exists but is not an item on the project +// - "wrong_field_type" — the named field is not the data type the caller expected +type StructuredResolutionError struct { + Kind string `json:"error"` + Name string `json:"name,omitempty"` + Field string `json:"field,omitempty"` + Candidates []any `json:"candidates,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// Error implements the error interface; the message is the JSON body so that the +// downstream tool result also carries the structured payload as plain text. +func (e *StructuredResolutionError) Error() string { + b, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf(`{"error":%q,"name":%q}`, e.Kind, e.Name) + } + return string(b) +} + +// NewStructuredResolutionError constructs a StructuredResolutionError. +func NewStructuredResolutionError(kind, name, hint string, candidates []any) *StructuredResolutionError { + return &StructuredResolutionError{ + Kind: kind, + Name: name, + Hint: hint, + Candidates: candidates, + } +} + +// NewStructuredResolutionErrorResponse returns an mcp.CallToolResult whose text body +// is the JSON-serialised StructuredResolutionError, suitable for agent self-correction. +func NewStructuredResolutionErrorResponse(err *StructuredResolutionError) *mcp.CallToolResult { + return utils.NewToolResultError(err.Error()) +} diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 864f61d83..6ca33768e 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -10,8 +10,15 @@ "description": "The field's ID. Required for 'get_project_field' method.", "type": "number" }, + "field_names": { + "description": "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Only used for 'get_project_item' method.", + "items": { + "type": "string" + }, + "type": "array" + }, "fields": { - "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Only used for 'get_project_item' method.", "items": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index c2bb0d3f4..3a858af99 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -14,8 +14,15 @@ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", "type": "string" }, + "field_names": { + "description": "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Only used for 'list_project_items' method.", + "items": { + "type": "string" + }, + "type": "array" + }, "fields": { - "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Only used for 'list_project_items' method.", "items": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 6c9d349f6..a735e2955 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -15,19 +15,19 @@ "type": "string" }, "issue_number": { - "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + "description": "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).", "type": "number" }, "item_id": { - "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + "description": "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.", "type": "number" }, "item_owner": { - "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "description": "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.", "type": "string" }, "item_repo": { - "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "description": "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.", "type": "string" }, "item_type": { @@ -125,7 +125,7 @@ "type": "string" }, "updated_field": { - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + "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, the value may be the option name (resolved server-side) or 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 8f24cde7e..f44a8ae82 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -195,7 +196,14 @@ Use this tool to list projects for a user or organization, or list project field }, "fields": { Type: "array", - Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "field_names": { + Type: "array", + Description: "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Only used for 'list_project_items' method.", Items: &jsonschema.Schema{ Type: "string", }, @@ -267,7 +275,11 @@ Use this tool to list projects for a user or organization, or list project field } return result, payload, err case projectsMethodListProjectItems: - result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) + gqlClient, gqlErr := deps.GetGQLClient(ctx) + if gqlErr != nil { + return utils.NewToolResultError(gqlErr.Error()), nil, nil + } + result, payload, err := listProjectItems(ctx, client, gqlClient, args, owner, ownerType) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { @@ -343,7 +355,14 @@ Use this tool to get details about individual projects, project fields, and proj }, "fields": { Type: "array", - Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "field_names": { + Type: "array", + Description: "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Only used for 'get_project_item' method.", Items: &jsonschema.Schema{ Type: "string", }, @@ -433,6 +452,25 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + fieldNames, err := OptionalStringArrayParam(args, "field_names") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(fieldNames) > 0 { + gqlClient, gqlErr := deps.GetGQLClient(ctx) + if gqlErr != nil { + return utils.NewToolResultError(gqlErr.Error()), nil, nil + } + resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + fields = append(fields, resolvedIDs...) + } result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) @@ -495,7 +533,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "item_id": { Type: "number", - Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + Description: "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.", }, "item_type": { Type: "string", @@ -504,15 +542,15 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "item_owner": { Type: "string", - Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + Description: "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.", }, "item_repo": { Type: "string", - Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + Description: "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.", }, "issue_number": { Type: "number", - Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + Description: "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).", }, "pull_request_number": { Type: "number", @@ -520,7 +558,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "updated_field": { Type: "object", - Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + 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, the value may be the option name (resolved server-side) or the option ID. Set value to null to clear the field.", }, "body": { Type: "string", @@ -652,10 +690,26 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType) case projectsMethodUpdateProjectItem: - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + var itemID int64 + if _, hasItemID := args["item_id"]; hasItemID { + id, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID = id + } else { + // Resolve the item by (item_owner, item_repo, issue_number). + resolvedItemID, resolveErr := resolveItemIDFromIssueArgs(ctx, gqlClient, owner, ownerType, projectNumber, args) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + itemID = resolvedItemID } + rawUpdatedField, exists := args["updated_field"] if !exists { return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil @@ -664,7 +718,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { if !ok || fieldValue == nil { return utils.NewToolResultError("updated_field must be an object"), nil, nil } - return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue) case projectsMethodDeleteProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -881,7 +935,7 @@ func listProjectFields(ctx context.Context, client *github.Client, args map[stri return utils.NewToolResultText(string(r)), nil, nil } -func listProjectItems(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { +func listProjectItems(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -897,6 +951,22 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin return utils.NewToolResultError(err.Error()), nil, nil } + fieldNames, err := OptionalStringArrayParam(args, "field_names") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(fieldNames) > 0 { + resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + fields = append(fields, resolvedIDs...) + } + pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1074,9 +1144,13 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType return utils.NewToolResultText(string(r)), nil, nil } -func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { - updatePayload, err := buildUpdateProjectItem(fieldValue) +func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(err, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } return utils.NewToolResultError(err.Error()), nil, nil } @@ -1450,25 +1524,77 @@ func validateAndConvertToInt64(value any) (int64, error) { } } -// buildUpdateProjectItem constructs UpdateProjectItemOptions from the input map. -func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOptions, 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) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } - idField, ok := input["id"] - if !ok { - return nil, fmt.Errorf("updated_field.id is required") + valueField, hasValue := input["value"] + if !hasValue { + return nil, fmt.Errorf("updated_field.value is required") } - fieldID, err := validateAndConvertToInt64(idField) - if err != nil { - return nil, fmt.Errorf("updated_field.id: %w", err) + idField, hasID := input["id"] + nameField, hasName := input["name"] + + switch { + case hasID && hasName: + return nil, fmt.Errorf("updated_field must set either id or name, not both") + case !hasID && !hasName: + return nil, fmt.Errorf("updated_field requires either id or name") } - valueField, ok := input["value"] - if !ok { - return nil, fmt.Errorf("updated_field.value is required") + var ( + fieldID int64 + resolved *ResolvedField + ) + + if hasID { + var err error + fieldID, err = validateAndConvertToInt64(idField) + if err != nil { + return nil, fmt.Errorf("updated_field.id: %w", err) + } + } else { + fieldName, ok := nameField.(string) + if !ok || fieldName == "" { + return nil, fmt.Errorf("updated_field.name must be a non-empty string") + } + if gqlClient == nil { + 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 err != nil { + return nil, err + } + parsedID, parseErr := parseInt64(resolved.ID) + if parseErr != nil { + return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) + } + fieldID = parsedID + } + + // SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID. + if resolved != nil && resolved.DataType == "SINGLE_SELECT" { + if str, ok := valueField.(string); ok && str != "" { + if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil { + valueField = optID + } else { + // Fall back: if the string is already a known option ID, accept it. + known := false + for _, opt := range resolved.Options { + if opt.ID == str { + known = true + break + } + } + if !known { + return nil, optErr + } + } + } } payload := &github.UpdateProjectItemOptions{ diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go new file mode 100644 index 000000000..e82cbb222 --- /dev/null +++ b/pkg/github/projects_resolver.go @@ -0,0 +1,385 @@ +package github + +import ( + "context" + "fmt" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/shurcooL/githubv4" +) + +// resolverFieldsPageSize is the GraphQL ProjectV2 max page size; covers most +// projects in a single round-trip. +const resolverFieldsPageSize = 100 + +// ResolvedFieldOption is one option on a SINGLE_SELECT project field. +type ResolvedFieldOption struct { + ID string + Name string +} + +// ResolvedField is a project field resolved by name; Options is only set when +// DataType == "SINGLE_SELECT". +type ResolvedField struct { + ID string + Name string + DataType string + Options []ResolvedFieldOption +} + +// projectFieldsQueryOrg fetches all fields on an org-owned project (paginated). +type projectFieldsQueryOrg struct { + Organization struct { + ProjectV2 struct { + Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +// projectFieldsQueryUser fetches all fields on a user-owned project (paginated). +type projectFieldsQueryUser struct { + User struct { + ProjectV2 struct { + Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +// projectFieldsConnection is a paginated list of project fields with the field +// union expanded so we get IDs, names, types, and options in one hop. +type projectFieldsConnection struct { + Nodes []struct { + ProjectV2Field struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2Field"` + ProjectV2IterationField struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + Options []struct { + ID githubv4.String + Name githubv4.String + } + } `graphql:"... on ProjectV2SingleSelectField"` + } + PageInfo PageInfoFragment +} + +// listAllProjectFields fetches every field on a project, paginating as needed. +func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { + all := []ResolvedField{} + var after *githubv4.String + + for { + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small + "first": githubv4.Int(resolverFieldsPageSize), + "after": (*githubv4.String)(nil), + } + if after != nil { + vars["after"] = after + } + + var conn projectFieldsConnection + if ownerType == "org" { + var q projectFieldsQueryOrg + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields: %w", err) + } + conn = q.Organization.ProjectV2.Fields + } else { + var q projectFieldsQueryUser + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields: %w", err) + } + conn = q.User.ProjectV2.Fields + } + + for _, n := range conn.Nodes { + switch { + case n.ProjectV2SingleSelectField.ID != nil && n.ProjectV2SingleSelectField.ID != "": + opts := make([]ResolvedFieldOption, 0, len(n.ProjectV2SingleSelectField.Options)) + for _, o := range n.ProjectV2SingleSelectField.Options { + opts = append(opts, ResolvedFieldOption{ID: string(o.ID), Name: string(o.Name)}) + } + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), + Name: string(n.ProjectV2SingleSelectField.Name), + DataType: string(n.ProjectV2SingleSelectField.DataType), + Options: opts, + }) + case n.ProjectV2IterationField.ID != nil && n.ProjectV2IterationField.ID != "": + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), + Name: string(n.ProjectV2IterationField.Name), + DataType: string(n.ProjectV2IterationField.DataType), + }) + case n.ProjectV2Field.ID != nil && n.ProjectV2Field.ID != "": + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%v", n.ProjectV2Field.ID), + Name: string(n.ProjectV2Field.Name), + DataType: string(n.ProjectV2Field.DataType), + }) + } + } + + if !bool(conn.PageInfo.HasNextPage) { + break + } + end := conn.PageInfo.EndCursor + after = &end + } + + return all, nil +} + +// resolveProjectFieldByName resolves a field by display name. Returns a +// structured error on not-found, ambiguous, or wrong-data-type (when +// expectedDataType is set) so the agent can self-correct. +func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { + if fieldName == "" { + return nil, fmt.Errorf("field name must not be empty") + } + + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + var matches []ResolvedField + for _, f := range all { + if f.Name == fieldName { + matches = append(matches, f) + } + } + + if len(matches) == 0 { + candidates := make([]any, 0, len(all)) + for _, f := range all { + 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, + ) + } + + if len(matches) > 1 { + 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, + ) + } + + field := matches[0] + + if expectedDataType != "" && field.DataType != expectedDataType { + return nil, ghErrors.NewStructuredResolutionError( + "wrong_field_type", + fieldName, + fmt.Sprintf("field %q has data type %q but %q was expected", fieldName, field.DataType, expectedDataType), + []any{map[string]any{"id": field.ID, "data_type": field.DataType}}, + ) + } + + return &field, nil +} + +// resolveSingleSelectOptionByName resolves an option name to its ID on a +// SINGLE_SELECT field. Returns a structured error if not found or ambiguous. +func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (string, error) { + if field == nil { + return "", fmt.Errorf("field must not be nil") + } + if field.DataType != "SINGLE_SELECT" { + return "", ghErrors.NewStructuredResolutionError( + "wrong_field_type", + field.Name, + fmt.Sprintf("cannot resolve option name on non-SINGLE_SELECT field %q (data type %q)", field.Name, field.DataType), + nil, + ) + } + + var matchIDs []string + for _, o := range field.Options { + if o.Name == optionName { + matchIDs = append(matchIDs, o.ID) + } + } + + switch len(matchIDs) { + case 0: + candidates := make([]any, 0, len(field.Options)) + for _, o := range field.Options { + candidates = append(candidates, map[string]any{"name": o.Name}) + } + return "", ghErrors.NewStructuredResolutionError( + "option_not_found", + optionName, + fmt.Sprintf("no option named %q on field %q; see candidates for available options", optionName, field.Name), + candidates, + ) + case 1: + return matchIDs[0], nil + default: + candidates := make([]any, 0, len(matchIDs)) + for _, id := range matchIDs { + candidates = append(candidates, map[string]any{"id": id}) + } + return "", ghErrors.NewStructuredResolutionError( + "option_ambiguous", + optionName, + fmt.Sprintf("multiple options on field %q share the name %q", field.Name, optionName), + candidates, + ) + } +} + +// resolveProjectItemIDByIssueNumber resolves a (project, issue) pair to the +// project item's full database ID in one GraphQL hop. Returns a structured +// error if the issue is not an item on the project. +func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (int64, error) { + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return 0, err + } + + var query struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } `graphql:"projectItems(first: 50, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` + } + + vars := map[string]any{ + "issueOwner": githubv4.String(issueOwner), + "issueRepo": githubv4.String(issueRepo), + "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small + } + + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + } + + for _, item := range query.Repository.Issue.ProjectItems.Nodes { + if fmt.Sprintf("%v", item.Project.ID) == fmt.Sprintf("%v", projectID) { + itemID, parseErr := parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + } + return itemID, nil + } + } + + return 0, ghErrors.NewStructuredResolutionError( + "item_not_in_project", + fmt.Sprintf("%s/%s#%d", issueOwner, issueRepo, issueNumber), + "the issue exists but is not an item on the named project; add it first via add_project_item", + nil, + ) +} + +// resolveItemIDFromIssueArgs reads (item_owner, item_repo, issue_number) from args +// and resolves them to a project item ID. Returns a single friendly error if any input is missing. +func resolveItemIDFromIssueArgs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (int64, error) { + issueOwner, ownerErr := RequiredParam[string](args, "item_owner") + issueRepo, repoErr := RequiredParam[string](args, "item_repo") + issueNumber, numErr := RequiredInt(args, "issue_number") + if ownerErr != nil || repoErr != nil || numErr != nil { + return 0, fmt.Errorf("update_project_item requires either item_id, or item_owner + item_repo + issue_number to resolve the item by issue") + } + return resolveProjectItemIDByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) +} + +// parseInt64 parses a decimal string into int64. +func parseInt64(s string) (int64, error) { + var n int64 + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +} + +// resolveFieldNamesToIDs resolves field names to numeric IDs in one GraphQL +// hop. Fails fast with a structured error on any unresolved or ambiguous name. +func resolveFieldNamesToIDs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, names []string) ([]int64, error) { + if len(names) == 0 { + return nil, nil + } + + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + // Build a name -> []ResolvedField map so we can detect duplicates per name. + byName := make(map[string][]ResolvedField, len(all)) + for _, f := range all { + byName[f.Name] = append(byName[f.Name], f) + } + + out := make([]int64, 0, len(names)) + for _, name := range names { + matches := byName[name] + switch len(matches) { + case 0: + candidates := make([]any, 0, len(all)) + for _, f := range all { + candidates = append(candidates, map[string]any{"name": f.Name, "data_type": f.DataType}) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + name, + fmt.Sprintf("no project field named %q on project %s#%d", name, owner, projectNumber), + candidates, + ) + case 1: + id, parseErr := parseInt64(matches[0].ID) + if parseErr != nil { + return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via 'fields' instead", name, matches[0].ID) + } + out = append(out, id) + 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", + name, + "multiple fields share this name; pass numeric IDs via 'fields' to disambiguate", + candidates, + ) + } + } + return out, nil +} diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go new file mode 100644 index 000000000..647790d96 --- /dev/null +++ b/pkg/github/projects_resolver_test.go @@ -0,0 +1,486 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// projectFieldsQueryMatcher is the GraphQL shape we use for fields(first:100) resolution. +// Keep this in sync with projectFieldsConnection in projects_resolver.go. +type projectFieldsTestQuery struct { + Organization struct { + ProjectV2 struct { + Fields struct { + Nodes []struct { + ProjectV2Field struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2Field"` + ProjectV2IterationField struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + Options []struct { + ID githubv4.String + Name githubv4.String + } + } `graphql:"... on ProjectV2SingleSelectField"` + } + PageInfo PageInfoFragment + } `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +func fieldsQueryVars(owner string, projectNumber int) map[string]any { + return map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec + "first": githubv4.Int(resolverFieldsPageSize), + "after": (*githubv4.String)(nil), + } +} + +// statusFieldNode is a single-select field response node for use in mock data. +func statusFieldNode(id, name string, options []map[string]any) map[string]any { + return map[string]any{ + "id": id, + "name": name, + "dataType": "SINGLE_SELECT", + "options": options, + } +} + +func fieldsResponse(nodes []map[string]any) map[string]any { + return map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "fields": map[string]any{ + "nodes": nodes, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + } +} + +func Test_ResolveProjectFieldByName_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("12345", "Status", []map[string]any{ + {"id": "OPT_a", "name": "Todo"}, + {"id": "OPT_b", "name": "In Progress"}, + {"id": "OPT_c", "name": "Done"}, + }), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT") + require.NoError(t, err) + require.NotNil(t, field) + assert.Equal(t, "12345", field.ID) + assert.Equal(t, "SINGLE_SELECT", field.DataType) + assert.Len(t, field.Options, 3) + + optionID, err := resolveSingleSelectOptionByName(field, "In Progress") + require.NoError(t, err) + assert.Equal(t, "OPT_b", optionID) +} + +func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("12345", "Status", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "field_not_found", msg["error"]) + assert.Equal(t, "Priority", msg["name"]) + assert.NotEmpty(t, msg["candidates"]) +} + +func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("12345", "Status", nil), + statusFieldNode("67890", "Status", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "field_ambiguous", msg["error"]) + candidates, _ := msg["candidates"].([]any) + assert.Len(t, candidates, 2) +} + +func Test_ResolveSingleSelectOptionByName_NotFound(t *testing.T) { + field := &ResolvedField{ + ID: "12345", + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{ + {ID: "OPT_a", Name: "Todo"}, + {ID: "OPT_b", Name: "Done"}, + }, + } + + _, err := resolveSingleSelectOptionByName(field, "Blocked") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "option_not_found", msg["error"]) + assert.Equal(t, "Blocked", msg["name"]) +} + +func Test_ResolveSingleSelectOptionByName_WrongFieldType(t *testing.T) { + field := &ResolvedField{ + ID: "12345", + Name: "Description", + DataType: "TEXT", + } + + _, err := resolveSingleSelectOptionByName(field, "anything") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "wrong_field_type", msg["error"]) +} + +// resolveItemByIssueQuery matches the GraphQL shape used by +// resolveProjectItemIDByIssueNumber for the issue.projectItems traversal. +type resolveItemByIssueQuery struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } `graphql:"projectItems(first: 50, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` +} + +func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + // project node id lookup (org) + 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 lookup + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "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": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + map[string]any{ + "fullDatabaseId": "4242", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + gql := githubv4.NewClient(mocked) + + itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.NoError(t, err) + assert.Equal(t, int64(4242), itemID) +} + +func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) { + mocked := 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", + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "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": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "item_not_in_project", msg["error"]) +} + +func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("100", "Status", nil), + statusFieldNode("200", "Priority", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Status", "Priority"}) + require.NoError(t, err) + assert.Equal(t, []int64{100, 200}, ids) +} + +// Test_ProjectsWrite_UpdateProjectItem_ByName is the acceptance test for the +// write side: set Status = "In Progress" using only names plus an issue number. +func Test_ProjectsWrite_UpdateProjectItem_ByName(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( + // 1. project node id (used by resolveProjectItemIDByIssueNumber) + 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"}, + }, + }), + ), + // 2. issue -> projectItems lookup + 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": "1001", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), + // 3. fields(first:100) for name resolution + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("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_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": float64(123), + "updated_field": map[string]any{ + "name": "Status", + "value": "In Progress", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) +} + +func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + mockedGQL := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("101", "Status", nil), + })), + ), + ) + gqlClient := githubv4.NewClient(mockedGQL) + 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_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "name": "Doesnt Exist", + "value": "whatever", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &msg)) + assert.Equal(t, "field_not_found", msg["error"]) + assert.Equal(t, "Doesnt Exist", msg["name"]) +}