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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user