MCP: name-based resolution for Projects fields (#2760)

* Let agents address Project fields, single-select options, and item field values by name through the GitHub MCP server

* Adding a method to resolve params resolveItemIDFromIssueArgs

* Adding the DatabaseID to be able to match the graphQL

* Changing the parsing to strconv.ParseInt

* Changing description.

* update readme

* Fixing copilot comments

* Adding Case-insensitive matching  and fields + field_names guard

* running snaps and readme
This commit is contained in:
Lizeth Vera
2026-07-10 10:25:32 -07:00
committed by GitHub
parent 439ec711fa
commit c36e4e4493
9 changed files with 1212 additions and 43 deletions
+9 -7
View File
@@ -1069,7 +1069,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. Mutually exclusive with 'fields' — provide one, not both. 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. Mutually exclusive with 'field_names' — provide one, not both. 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)
@@ -1082,7 +1083,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. Mutually exclusive with 'fields' — provide one, not both. 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. Mutually exclusive with 'field_names' — provide one, not both. 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)
@@ -1094,10 +1096,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)
@@ -1110,7 +1112,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, 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)
</details>
+46
View File
@@ -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())
}
+8 -1
View File
@@ -11,8 +11,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. Mutually exclusive with 'fields' — provide one, not both. 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. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'get_project_item' method.",
"items": {
"type": "string"
},
+8 -1
View File
@@ -15,8 +15,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. Mutually exclusive with 'fields' — provide one, not both. 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. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method.",
"items": {
"type": "string"
},
+5 -5
View File
@@ -17,19 +17,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": {
@@ -127,7 +127,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, 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"
}
},
+161 -29
View File
@@ -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. Mutually exclusive with 'field_names' — provide one, not both. 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. Mutually exclusive with 'fields' — provide one, not both. 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. Mutually exclusive with 'field_names' — provide one, not both. 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. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method.",
Items: &jsonschema.Schema{
Type: "string",
},
@@ -433,6 +452,28 @@ 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(fields) > 0 && len(fieldNames) > 0 {
return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), 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 +536,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 +545,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 +561,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, 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.",
},
"body": {
Type: "string",
@@ -652,10 +693,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 +721,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 +938,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 +954,25 @@ 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(fields) > 0 && len(fieldNames) > 0 {
return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), 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 +1150,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,27 +1530,79 @@ 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")
}
fieldID, err := validateAndConvertToInt64(idField)
if err != nil {
return nil, fmt.Errorf("updated_field.id: %w", err)
}
valueField, ok := input["value"]
if !ok {
valueField, hasValue := input["value"]
if !hasValue {
return nil, fmt.Errorf("updated_field.value is required")
}
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")
}
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{
Fields: []*github.UpdateProjectV2Field{{
ID: fieldID,
+389
View File
@@ -0,0 +1,389 @@
package github
import (
"context"
"fmt"
"strconv"
"strings"
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. We select `id`
// to discriminate the union variant and `databaseId` for the numeric ID REST needs.
type projectFieldsConnection struct {
Nodes []struct {
ProjectV2Field struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2IterationField"`
ProjectV2SingleSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
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:
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("%d", n.ProjectV2SingleSelectField.DatabaseID),
Name: string(n.ProjectV2SingleSelectField.Name),
DataType: string(n.ProjectV2SingleSelectField.DataType),
Options: opts,
})
case n.ProjectV2IterationField.ID != nil:
all = append(all, ResolvedField{
ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID),
Name: string(n.ProjectV2IterationField.Name),
DataType: string(n.ProjectV2IterationField.DataType),
})
case n.ProjectV2Field.ID != nil:
all = append(all, ResolvedField{
ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID),
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 strings.EqualFold(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 strings.EqualFold(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 item.Project.ID == 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)
}
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
// 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.
// Matching is case-insensitive to align with the GraphQL API's behaviour.
byName := make(map[string][]ResolvedField, len(all))
for _, f := range all {
key := strings.ToLower(f.Name)
byName[key] = append(byName[key], f)
}
out := make([]int64, 0, len(names))
for _, name := range names {
matches := byName[strings.ToLower(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
}
+539
View File
@@ -0,0 +1,539 @@
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
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
} `graphql:"... on ProjectV2IterationField"`
ProjectV2SingleSelectField struct {
ID githubv4.ID
DatabaseID githubv4.Int `graphql:"databaseId"`
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.
// `nodeID` is the global node ID (e.g. "PVTSSF_lADO...") and `databaseID` is
// the numeric database ID the REST API expects.
func statusFieldNode(nodeID string, databaseID int, name string, options []map[string]any) map[string]any {
return map[string]any{
"id": nodeID,
"databaseId": databaseID,
"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("PVTSSF_lADOBBcDeFg123", 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("PVTSSF_lADOBBcDeFg123", 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("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg678", 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("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg200", 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)
}
// Field and single-select option name matching is case-insensitive so agents passing lowercase
// names like "status" or "in progress" resolve to "Status" and "In Progress" respectively.
func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 7),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{
{"id": "OPT_a", "name": "Todo"},
{"id": "OPT_b", "name": "In Progress"},
}),
})),
),
)
gql := githubv4.NewClient(mocked)
field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "status", "")
require.NoError(t, err)
require.NotNil(t, field)
assert.Equal(t, "12345", field.ID)
optionID, err := resolveSingleSelectOptionByName(field, "in progress")
require.NoError(t, err)
assert.Equal(t, "OPT_b", optionID)
}
// Test_ResolveFieldNamesToIDs_CaseInsensitive verifies bulk name resolution
// also matches case-insensitively.
func Test_ResolveFieldNamesToIDs_CaseInsensitive(t *testing.T) {
mocked := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
projectFieldsTestQuery{},
fieldsQueryVars("octo-org", 1),
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil),
statusFieldNode("PVTSSF_lADOBBcDeFg200", 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("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_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("PVTSSF_lADOBBcDeFg101", 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"])
}
+47
View File
@@ -364,6 +364,29 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) {
require.True(t, ok)
assertMinimalPullRequestProjectItem(t, textContent.Text, item)
})
t.Run("rejects fields and field_names together", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"fields": []any{"100"},
"field_names": []any{"Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "provide either 'fields' or 'field_names', not both")
})
}
func Test_detectOwnerType(t *testing.T) {
@@ -811,6 +834,30 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) {
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "missing required parameter: item_id")
})
t.Run("rejects fields and field_names together", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project_item",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
"item_id": float64(1001),
"fields": []any{"100"},
"field_names": []any{"Status"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.True(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "provide either 'fields' or 'field_names', not both")
})
}
func Test_ProjectsWrite(t *testing.T) {