diff --git a/README.md b/README.md index 5d6caae6d..c285f6806 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,7 @@ The following sets of tools are available: - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. @@ -874,6 +875,12 @@ The following sets of tools are available: - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` @@ -883,6 +890,7 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) - `labels`: Filter by labels (string[], optional) - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - `owner`: Repository owner (string, required) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index a3074bdd2..8fce0b177 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -57,29 +57,6 @@ runtime behavior (such as output formatting) won't appear here. - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) - - `issue_number`: Issue number to update (number, optional) - - `labels`: Labels to apply to this issue (string[], optional) - - `method`: Write operation to perform on a single issue. - Options are: - - 'create' - creates a new issue. - - 'update' - updates an existing issue. - (string, required) - - `milestone`: Milestone number (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - - `state`: New state (string, optional) - - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - -### `remote_mcp_issue_fields` - -- **issue_write** - Create or update issue/pull request - - **Required OAuth Scopes**: `repo` - - `assignees`: Usernames to assign to this issue (string[], optional) - - `body`: Issue body content (string, optional) - - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) @@ -96,25 +73,6 @@ runtime behavior (such as output formatting) won't appear here. - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) -- **list_issue_fields** - List issue fields - - **Required OAuth Scopes**: `repo`, `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` - - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) - - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) - -- **list_issues** - List issues - - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) - - `labels`: Filter by labels (string[], optional) - - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - - `owner`: Repository owner (string, required) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - ### `issues_granular` - **add_sub_issue** - Add Sub-Issue diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 8ad297e30..6317732b4 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -51,29 +51,6 @@ The list below is generated from the Go source. It covers tool **inventory and s - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) - - `issue_number`: Issue number to update (number, optional) - - `labels`: Labels to apply to this issue (string[], optional) - - `method`: Write operation to perform on a single issue. - Options are: - - 'create' - creates a new issue. - - 'update' - updates an existing issue. - (string, required) - - `milestone`: Milestone number (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - - `state`: New state (string, optional) - - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - - `title`: Issue title (string, optional) - - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - -### `remote_mcp_issue_fields` - -- **issue_write** - Create or update issue/pull request - - **Required OAuth Scopes**: `repo` - - `assignees`: Usernames to assign to this issue (string[], optional) - - `body`: Issue body content (string, optional) - - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) @@ -90,25 +67,6 @@ The list below is generated from the Go source. It covers tool **inventory and s - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) -- **list_issue_fields** - List issue fields - - **Required OAuth Scopes**: `repo`, `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` - - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) - - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) - -- **list_issues** - List issues - - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) - - `labels`: Filter by labels (string[], optional) - - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - - `owner`: Repository owner (string, required) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - ### `file_blame` - **get_file_blame** - Get file blame information diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 43e0317d6..13d4c8ced 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -29,6 +29,42 @@ "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", "type": "number" }, + "issue_fields": { + "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + "items": { + "additionalProperties": false, + "properties": { + "delete": { + "description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value' or 'field_option_name'.", + "enum": [ + true + ], + "type": "boolean" + }, + "field_name": { + "description": "Issue field name (case-insensitive). Must match a field returned by list_issue_fields for this repository or its organization.", + "type": "string" + }, + "field_option_name": { + "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete'.", + "type": "string" + }, + "value": { + "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete'.", + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "required": [ + "field_name" + ], + "type": "object" + }, + "type": "array" + }, "issue_number": { "description": "Issue number to update", "type": "number" diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap deleted file mode 100644 index dc373d47c..000000000 --- a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap +++ /dev/null @@ -1,137 +0,0 @@ -{ - "_meta": { - "ui": { - "resourceUri": "ui://github-mcp-server/issue-write", - "visibility": [ - "model", - "app" - ] - } - }, - "annotations": { - "title": "Create or update issue/pull request" - }, - "description": "Create a new or update an existing issue in a GitHub repository.", - "inputSchema": { - "properties": { - "assignees": { - "description": "Usernames to assign to this issue", - "items": { - "type": "string" - }, - "type": "array" - }, - "body": { - "description": "Issue body content", - "type": "string" - }, - "duplicate_of": { - "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", - "type": "number" - }, - "issue_fields": { - "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", - "items": { - "additionalProperties": false, - "properties": { - "delete": { - "description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value' or 'field_option_name'.", - "enum": [ - true - ], - "type": "boolean" - }, - "field_name": { - "description": "Issue field name (case-insensitive). Must match a field returned by list_issue_fields for this repository or its organization.", - "type": "string" - }, - "field_option_name": { - "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete'.", - "type": "string" - }, - "value": { - "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete'.", - "type": [ - "string", - "number", - "boolean" - ] - } - }, - "required": [ - "field_name" - ], - "type": "object" - }, - "type": "array" - }, - "issue_number": { - "description": "Issue number to update", - "type": "number" - }, - "labels": { - "description": "Labels to apply to this issue", - "items": { - "type": "string" - }, - "type": "array" - }, - "method": { - "description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n", - "enum": [ - "create", - "update" - ], - "type": "string" - }, - "milestone": { - "description": "Milestone number", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "show_ui": { - "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", - "type": "boolean" - }, - "state": { - "description": "New state", - "enum": [ - "open", - "closed" - ], - "type": "string" - }, - "state_reason": { - "description": "Reason for the state change. Ignored unless state is changed.", - "enum": [ - "completed", - "not_planned", - "duplicate" - ], - "type": "string" - }, - "title": { - "description": "Issue title", - "type": "string" - }, - "type": { - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", - "type": "string" - } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" - }, - "name": "issue_write" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 8ce261d7c..53a951846 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -18,6 +18,27 @@ ], "type": "string" }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, "labels": { "description": "Filter by labels", "items": { diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap deleted file mode 100644 index 53a951846..000000000 --- a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap +++ /dev/null @@ -1,92 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List issues" - }, - "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", - "inputSchema": { - "properties": { - "after": { - "description": "Cursor for pagination. Use the cursor from the previous response.", - "type": "string" - }, - "direction": { - "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", - "enum": [ - "ASC", - "DESC" - ], - "type": "string" - }, - "field_filters": { - "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", - "items": { - "properties": { - "field_name": { - "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", - "type": "string" - }, - "value": { - "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", - "type": "string" - } - }, - "required": [ - "field_name", - "value" - ], - "type": "object" - }, - "type": "array" - }, - "labels": { - "description": "Filter by labels", - "items": { - "type": "string" - }, - "type": "array" - }, - "orderBy": { - "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", - "enum": [ - "CREATED_AT", - "UPDATED_AT", - "COMMENTS" - ], - "type": "string" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "since": { - "description": "Filter by date (ISO 8601 timestamp)", - "type": "string" - }, - "state": { - "description": "Filter by state, by default both open and closed issues are returned when not provided", - "enum": [ - "OPEN", - "CLOSED" - ], - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "name": "list_issues" -} \ No newline at end of file diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index b9ff2e3ed..5cc6fe7e5 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -40,9 +40,9 @@ func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) - enabledOnly.FeatureFlagEnable = FeatureFlagIssueFields + enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) - disabledOnly.FeatureFlagDisable = []string{FeatureFlagIssueFields} + disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame} tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) require.Len(t, tools, 2) diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 835179532..fa376eaa2 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -11,11 +11,6 @@ const FeatureFlagCSVOutput = "csv_output" // FeatureFlagIFCLabels is the feature flag name for IFC security labels in tool results. const FeatureFlagIFCLabels = "ifc_labels" -// FeatureFlagIssueFields is the feature flag name for Issues 2.0 custom field -// support: the list_issue_fields tool, the field_filters input on list_issues, -// and field_values enrichment in list_issues / search_issues output. -const FeatureFlagIssueFields = "remote_mcp_issue_fields" - // FeatureFlagFileBlame is the feature flag name for the get_file_blame tool, // which exposes git blame information for a file. It is gated so the extra tool // is not advertised by default, keeping the tool surface small unless opted in. @@ -29,7 +24,6 @@ var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIFCLabels, - FeatureFlagIssueFields, FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular, FeatureFlagFileBlame, @@ -42,7 +36,6 @@ var AllowedFeatureFlags = []string{ var InsidersFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, - FeatureFlagIssueFields, FeatureFlagFileBlame, } diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 27a4a09c5..e06bdbfcf 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -104,7 +104,6 @@ type issueFieldsOrgQuery struct { } // ListIssueFields creates a tool to list issue field definitions for a repository or organization. -// Gated by FeatureFlagIssueFields: the tool is only registered when the flag is on. func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataIssues, @@ -168,7 +167,6 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool } return result, nil, nil }) - st.FeatureFlagEnable = FeatureFlagIssueFields return st } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 79b8b23ad..1a4a490e3 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -605,123 +605,6 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any { } } -// --- Legacy list_issues GraphQL types --- -// -// These mirror the pre-Issues-2.0 shape of the list_issues query and exist solely -// to back the FeatureFlagIssueFields-disabled variant of the tool. They omit the -// IssueFieldValues selection and the filterBy: {issueFieldValues: ...} clause so -// the request does not depend on server-side issue_fields GraphQL features and -// does not pay the wire/server cost of fetching custom field values when the flag -// is off. Delete this whole block (and its callers) when FeatureFlagIssueFields -// is removed. - -type LegacyIssueFragment struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - State githubv4.String - DatabaseID int64 - - Author struct { - Login githubv4.String - } - CreatedAt githubv4.DateTime - UpdatedAt githubv4.DateTime - Labels struct { - Nodes []struct { - Name githubv4.String - ID githubv4.String - Description githubv4.String - } - } `graphql:"labels(first: 100)"` - Comments struct { - TotalCount githubv4.Int - } `graphql:"comments"` -} - -type LegacyIssueQueryFragment struct { - Nodes []LegacyIssueFragment `graphql:"nodes"` - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int -} - -type LegacyIssueQueryResult interface { - GetLegacyIssueFragment() LegacyIssueQueryFragment - GetIsPrivate() bool -} - -type LegacyListIssuesQuery struct { - Repository struct { - Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - IsPrivate githubv4.Boolean - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type LegacyListIssuesQueryTypeWithLabels struct { - Repository struct { - Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - IsPrivate githubv4.Boolean - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type LegacyListIssuesQueryWithSince struct { - Repository struct { - Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` - IsPrivate githubv4.Boolean - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type LegacyListIssuesQueryTypeWithLabelsWithSince struct { - Repository struct { - Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` - IsPrivate githubv4.Boolean - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -func (q *LegacyListIssuesQuery) GetLegacyIssueFragment() LegacyIssueQueryFragment { - return q.Repository.Issues -} -func (q *LegacyListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } - -func (q *LegacyListIssuesQueryTypeWithLabels) GetLegacyIssueFragment() LegacyIssueQueryFragment { - return q.Repository.Issues -} -func (q *LegacyListIssuesQueryTypeWithLabels) GetIsPrivate() bool { - return bool(q.Repository.IsPrivate) -} - -func (q *LegacyListIssuesQueryWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { - return q.Repository.Issues -} -func (q *LegacyListIssuesQueryWithSince) GetIsPrivate() bool { - return bool(q.Repository.IsPrivate) -} - -func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { - return q.Repository.Issues -} -func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { - return bool(q.Repository.IsPrivate) -} - -func getLegacyIssueQueryType(hasLabels bool, hasSince bool) any { - switch { - case hasLabels && hasSince: - return &LegacyListIssuesQueryTypeWithLabelsWithSince{} - case hasLabels: - return &LegacyListIssuesQueryTypeWithLabels{} - case hasSince: - return &LegacyListIssuesQueryWithSince{} - default: - return &LegacyListIssuesQuery{} - } -} - // IssueRead creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -874,16 +757,14 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, minimalIssue := convertToMinimalIssue(issue) - // Always drop the verbose REST IssueFieldValues; only enrich with the GraphQL - // field_values view when the issue-fields feature flag is on. + // Always drop the verbose REST IssueFieldValues; enrich with the GraphQL + // field_values view instead. minimalIssue.IssueFieldValues = nil - if deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { - if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { - gqlClient, err := deps.GetGQLClient(ctx) - if err == nil { - if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil { - minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID] - } + if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { + gqlClient, err := deps.GetGQLClient(ctx) + if err == nil { + if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil { + minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID] } } } @@ -1783,12 +1664,8 @@ func issueWriteHasNonFormParams(args map[string]any) bool { return false } -// IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write -// (with the issue_fields parameter). LegacyIssueWrite is served when the flag -// is off. Both register under the tool name "issue_write"; exactly one is -// active at a time via mutually exclusive feature-flag annotations. When the -// flag is removed, delete LegacyIssueWrite outright and drop the feature-flag -// fields on IssueWrite. +// IssueWrite creates a tool to create or update an issue in a GitHub repository. +// It exposes the issue_fields parameter for setting Issues 2.0 custom field values. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataIssues, @@ -2069,251 +1946,10 @@ Options are: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } }) - st.FeatureFlagEnable = FeatureFlagIssueFields st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} return st } -// LegacyIssueWrite is the FeatureFlagIssueFields-disabled variant of issue_write. -// It is a near-verbatim copy of IssueWrite minus the issue_fields schema -// property, the issue_fields handler block, and the related GraphQL field -// resolution. Kept as a full duplicate so removing the FeatureFlagIssueFields -// flag is a single-function delete. Hidden whenever the granular toolset or -// the issue-fields flag is on. -func LegacyIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( - ToolsetMetadataIssues, - mcp.Tool{ - Name: "issue_write", - Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue/pull request"), - ReadOnlyHint: false, - }, - Meta: mcp.Meta{ - "ui": map[string]any{ - "resourceUri": IssueWriteUIResourceURI, - "visibility": []string{"model", "app"}, - }, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "method": { - Type: "string", - Description: `Write operation to perform on a single issue. -Options are: -- 'create' - creates a new issue. -- 'update' - updates an existing issue. -`, - Enum: []any{"create", "update"}, - }, - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "issue_number": { - Type: "number", - Description: "Issue number to update", - }, - "title": { - Type: "string", - Description: "Issue title", - }, - "body": { - Type: "string", - Description: "Issue body content", - }, - "assignees": { - Type: "array", - Description: "Usernames to assign to this issue", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "labels": { - Type: "array", - Description: "Labels to apply to this issue", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "milestone": { - Type: "number", - Description: "Milestone number", - }, - "type": { - Type: "string", - Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", - }, - "state": { - Type: "string", - Description: "New state", - Enum: []any{"open", "closed"}, - }, - "state_reason": { - Type: "string", - Description: "Reason for the state change. Ignored unless state is changed.", - Enum: []any{"completed", "not_planned", "duplicate"}, - }, - "duplicate_of": { - Type: "number", - Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", - }, - // show_ui is hidden from clients that do not advertise MCP App - // UI support. The strip happens per-request in - // inventory.ToolsForRegistration; it is present in the static - // schema (and therefore in toolsnaps and the feature-flag / - // insiders docs) so the UI-capable surface is fully - // documented. It is intentionally not in the main README, - // which renders the stripped (non-UI) schema. - "show_ui": { - Type: "boolean", - Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", - }, - }, - Required: []string{"method", "owner", "repo"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless: - // - it is itself a form submission (the UI sends _ui_submitted=true), - // - the caller explicitly asked to skip the UI (show_ui=false), or - // - it carries parameters the form cannot represent (e.g. labels, - // assignees or issue_fields). Those must be applied directly so - // their values aren't silently dropped. - uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { - if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") - if numErr != nil { - return utils.NewToolResultError("issue_number is required for update method"), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil - } - - title, err := OptionalParam[string](args, "title") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Optional parameters - body, err := OptionalParam[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get assignees - assignees, err := OptionalStringArrayParam(args, "assignees") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - assigneesValue, assigneesProvided := args["assignees"] - assigneesProvided = assigneesProvided && assigneesValue != nil - - // Get labels - labels, err := OptionalStringArrayParam(args, "labels") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - labelsValue, labelsProvided := args["labels"] - labelsProvided = labelsProvided && labelsValue != nil - - // Get optional milestone - milestone, err := OptionalIntParam(args, "milestone") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var milestoneNum int - if milestone != 0 { - milestoneNum = milestone - } - - // Get optional type - issueType, err := OptionalParam[string](args, "type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Handle state, state_reason and duplicateOf parameters - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - stateReason, err := OptionalParam[string](args, "state_reason") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - duplicateOf, err := OptionalIntParam(args, "duplicate_of") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if duplicateOf != 0 && stateReason != "duplicate" { - return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - gqlClient, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil - } - - switch method { - case "create": - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, nil) - return result, nil, err - case "update": - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{ - AssigneesProvided: assigneesProvided, - LabelsProvided: labelsProvided, - }) - return result, nil, err - default: - return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil - } - }) - st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular, FeatureFlagIssueFields} - return st -} - func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) { if title == "" { return utils.NewToolResultError("missing required parameter: title"), nil @@ -2535,11 +2171,8 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 return utils.NewToolResultText(string(r)), nil } -// ListIssues creates a tool to list and filter repository issues. This variant is -// gated by FeatureFlagIssueFields and exposes the Issues 2.0 field_filters input -// plus field_values output enrichment. When the flag is off, LegacyListIssues is -// served instead. Both registrations share the tool name "list_issues" and rely on -// the inventory's feature-flag filter to make exactly one active at a time. +// ListIssues creates a tool to list and filter repository issues. It exposes the +// Issues 2.0 field_filters input plus field_values output enrichment. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -2796,205 +2429,6 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil }) - st.FeatureFlagEnable = FeatureFlagIssueFields - return st -} - -// LegacyListIssues is the FeatureFlagIssueFields-disabled variant of list_issues. -// It exposes the pre-Issues-2.0 schema (no field_filters) and uses a GraphQL query -// path that does not select issueFieldValues or pass the issue_fields filter, so -// the request does not depend on server-side issue_fields features and does not pay -// for custom field values when the flag is off. Both this and ListIssues register -// under the tool name "list_issues"; exactly one is active for any given request -// thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. -// Delete this function (and the rest of the Legacy* block) when the flag is removed. -func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { - schema := &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "state": { - Type: "string", - Description: "Filter by state, by default both open and closed issues are returned when not provided", - Enum: []any{"OPEN", "CLOSED"}, - }, - "labels": { - Type: "array", - Description: "Filter by labels", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "orderBy": { - Type: "string", - Description: "Order issues by field. If provided, the 'direction' also needs to be provided.", - Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"}, - }, - "direction": { - Type: "string", - Description: "Order direction. If provided, the 'orderBy' also needs to be provided.", - Enum: []any{"ASC", "DESC"}, - }, - "since": { - Type: "string", - Description: "Filter by date (ISO 8601 timestamp)", - }, - }, - Required: []string{"owner", "repo"}, - } - WithCursorPagination(schema) - - st := NewTool( - ToolsetMetadataIssues, - mcp.Tool{ - Name: "list_issues", - Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), - ReadOnlyHint: true, - }, - InputSchema: schema, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state = strings.ToUpper(state) - var states []githubv4.IssueState - switch state { - case "OPEN", "CLOSED": - states = []githubv4.IssueState{githubv4.IssueState(state)} - default: - states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} - } - - labels, err := OptionalStringArrayParam(args, "labels") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - orderBy, err := OptionalParam[string](args, "orderBy") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - direction, err := OptionalParam[string](args, "direction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - orderBy = strings.ToUpper(orderBy) - switch orderBy { - case "CREATED_AT", "UPDATED_AT", "COMMENTS": - default: - orderBy = "CREATED_AT" - } - direction = strings.ToUpper(direction) - switch direction { - case "ASC", "DESC": - default: - direction = "DESC" - } - - since, err := OptionalParam[string](args, "since") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - var sinceTime time.Time - var hasSince bool - if since != "" { - sinceTime, err = parseISOTimestamp(since) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil - } - hasSince = true - } - hasLabels := len(labels) > 0 - - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return nil, nil, err - } - if _, pageProvided := args["page"]; pageProvided { - return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil - } - _, perPageProvided := args["perPage"] - paginationExplicit := perPageProvided - paginationParams, err := pagination.ToGraphQLParams() - if err != nil { - return nil, nil, err - } - if !paginationExplicit { - defaultFirst := int32(DefaultGraphQLPageSize) - paginationParams.First = &defaultFirst - } - - client, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil - } - - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), - } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - vars["after"] = (*githubv4.String)(nil) - } - if hasLabels { - labelStrings := make([]githubv4.String, len(labels)) - for i, label := range labels { - labelStrings[i] = githubv4.String(label) - } - vars["labels"] = labelStrings - } - if hasSince { - vars["since"] = githubv4.DateTime{Time: sinceTime} - } - - issueQuery := getLegacyIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse( - ctx, - "failed to list issues", - err, - ), nil, nil - } - - var resp MinimalIssuesResponse - var isPrivate bool - if queryResult, ok := issueQuery.(LegacyIssueQueryResult); ok { - resp = convertLegacyToMinimalIssuesResponse(queryResult.GetLegacyIssueFragment()) - isPrivate = queryResult.GetIsPrivate() - } - - result := MarshalledTextResult(resp) - result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) - return result, nil, nil - }) - st.FeatureFlagDisable = []string{FeatureFlagIssueFields} return st } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5378ff62b..39f043645 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -394,9 +394,9 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { } func Test_GetIssue_FieldValues(t *testing.T) { - // Verify that issue_field_values from the REST API are NOT exposed when the - // remote_mcp_issue_fields flag is off. The raw REST format is always cleared; - // enriched field_values are only populated when the flag is on. + // The raw REST issue_field_values are always cleared. Enriched field_values are + // only populated via GraphQL when the issue has a node ID; this issue has none, + // so field_values stays empty. serverTool := IssueRead(translations.NullTranslationHelper) mockIssueWithFields := &github.Issue{ @@ -459,15 +459,15 @@ func Test_GetIssue_FieldValues(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - // Flag is off: raw REST IssueFieldValues must be cleared, enriched FieldValues absent. - assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed when flag is off") - assert.Empty(t, returnedIssue.FieldValues, "enriched field_values should not be present when flag is off") + // Raw REST IssueFieldValues must be cleared, and no enriched field_values are + // present because this issue has no node ID. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed") + assert.Empty(t, returnedIssue.FieldValues, "enriched field_values should not be present without a node ID") } -func Test_GetIssue_FieldValues_FlagOn(t *testing.T) { - // Verify the enriched field_values are populated via GraphQL when the - // remote_mcp_issue_fields flag is on, and the raw REST issue_field_values - // stays cleared. +func Test_GetIssue_FieldValues_Enriched(t *testing.T) { + // Verify the enriched field_values are populated via GraphQL when the issue has + // a node ID, and the raw REST issue_field_values stays cleared. serverTool := IssueRead(translations.NullTranslationHelper) mockIssueWithFields := &github.Issue{ @@ -528,7 +528,6 @@ func Test_GetIssue_FieldValues_FlagOn(t *testing.T) { Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, RepoAccessCache: cache, - featureChecker: featureCheckerFor(FeatureFlagIssueFields), } handler := serverTool.Handler(deps) @@ -549,11 +548,11 @@ func Test_GetIssue_FieldValues_FlagOn(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - // Raw REST IssueFieldValues is always cleared, even when flag is on. - assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed even when flag is on") + // Raw REST IssueFieldValues is always cleared. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed") // Enriched FieldValues comes from the GraphQL nodes() round-trip. - require.Len(t, returnedIssue.FieldValues, 2, "field_values should be populated from GraphQL when flag is on") + require.Len(t, returnedIssue.FieldValues, 2, "field_values should be populated from GraphQL") assert.Equal(t, "priority", returnedIssue.FieldValues[0].Field) assert.Equal(t, "P1", returnedIssue.FieldValues[0].Value) assert.Equal(t, "estimate", returnedIssue.FieldValues[1].Field) @@ -1249,9 +1248,8 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ - Client: mustNewGHClient(t, restClient), - GQLClient: gqlClient, - featureChecker: featureCheckerFor(FeatureFlagIssueFields), + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, } handler := serverTool.Handler(deps) @@ -1279,11 +1277,11 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { } func Test_CreateIssue(t *testing.T) { - // Verify tool definition once (flag-enabled variant snap) + // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) - require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Empty(t, serverTool.FeatureFlagEnable) assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1962,8 +1960,8 @@ func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) - require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Empty(t, serverTool.FeatureFlagEnable) assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -2880,118 +2878,11 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } -func Test_LegacyListIssues_Definition(t *testing.T) { - serverTool := LegacyListIssues(translations.NullTranslationHelper) - tool := serverTool.Tool - - // LegacyListIssues claims the base tool name "list_issues" and produces the - // FeatureFlagIssueFields-disabled schema (no field_filters). It owns the - // canonical list_issues.snap; the FeatureFlagIssueFields-enabled variant - // owns list_issues_ff_.snap. - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - require.Equal(t, "list_issues", tool.Name) - require.Equal(t, []string{FeatureFlagIssueFields}, serverTool.FeatureFlagDisable) - require.Empty(t, serverTool.FeatureFlagEnable) - - props := tool.InputSchema.(*jsonschema.Schema).Properties - assert.Contains(t, props, "owner") - assert.Contains(t, props, "repo") - assert.Contains(t, props, "state") - assert.Contains(t, props, "labels") - assert.Contains(t, props, "since") - assert.NotContains(t, props, "field_filters", "legacy list_issues must not advertise field_filters") -} - -func Test_LegacyIssueWrite_Definition(t *testing.T) { - serverTool := LegacyIssueWrite(translations.NullTranslationHelper) - tool := serverTool.Tool - - // LegacyIssueWrite owns the canonical issue_write.snap; the - // FeatureFlagIssueFields-enabled variant owns issue_write_ff_.snap. - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - require.Equal(t, "issue_write", tool.Name) - require.Equal(t, []string{FeatureFlagIssuesGranular, FeatureFlagIssueFields}, serverTool.FeatureFlagDisable) - require.Empty(t, serverTool.FeatureFlagEnable) - - props := tool.InputSchema.(*jsonschema.Schema).Properties - assert.Contains(t, props, "method") - assert.Contains(t, props, "owner") - assert.Contains(t, props, "repo") - assert.NotContains(t, props, "issue_fields", "legacy issue_write must not advertise issue_fields") -} - -func Test_LegacyListIssues_OmitsFieldValuesAndFilters(t *testing.T) { - t.Parallel() - - serverTool := LegacyListIssues(translations.NullTranslationHelper) - - mockIssues := []map[string]any{ - { - "number": 7, - "title": "Legacy issue", - "body": "body", - "state": "OPEN", - "databaseId": 7, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z", - "author": map[string]any{"login": "octocat"}, - "labels": map[string]any{"nodes": []map[string]any{}}, - "comments": map[string]any{"totalCount": 0}, - }, - } - pageInfo := map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "c1", - "endCursor": "c1", - } - - // The legacy query must NOT reference issueFieldValues (neither in the selection - // set nor in filterBy). The matcher's query string therefore omits both. - const legacyQuery = "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - vars := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": nil, - } - response := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "isPrivate": false, - "issues": map[string]any{ - "nodes": mockIssues, - "pageInfo": pageInfo, - "totalCount": 1, - }, - }, - }) - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(legacyQuery, vars, response))) - - deps := BaseDeps{GQLClient: gqlClient} - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, "expected non-error result; got: %v", getTextResult(t, result).Text) - - var resp MinimalIssuesResponse - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &resp)) - require.Len(t, resp.Issues, 1) - assert.Equal(t, 7, resp.Issues[0].Number) - assert.Nil(t, resp.Issues[0].FieldValues, "legacy list_issues must not return field_values") -} - func Test_UpdateIssue(t *testing.T) { - // Verify tool definition (flag-enabled variant snap) + // Verify tool definition serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) @@ -3566,8 +3457,8 @@ func Test_UpdateIssue(t *testing.T) { } } -func Test_LegacyUpdateIssueClearsLabelsAndAssignees(t *testing.T) { - serverTool := LegacyIssueWrite(translations.NullTranslationHelper) +func Test_UpdateIssueClearsLabelsAndAssignees(t *testing.T) { + serverTool := IssueWrite(translations.NullTranslationHelper) updatedIssue := &github.Issue{ Number: github.Ptr(8), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"), diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index eff6edc13..256bdcb91 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -636,51 +636,6 @@ func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesRe } } -// legacyFragmentToMinimalIssue converts the FeatureFlagIssueFields-disabled -// LegacyIssueFragment into a MinimalIssue. MinimalIssue.FieldValues is left -// nil so omitempty drops it from JSON output. Delete with the rest of the -// Legacy* block when the flag is removed. -func legacyFragmentToMinimalIssue(fragment LegacyIssueFragment) MinimalIssue { - m := MinimalIssue{ - Number: int(fragment.Number), - Title: sanitize.Sanitize(string(fragment.Title)), - Body: sanitize.Sanitize(string(fragment.Body)), - State: string(fragment.State), - Comments: int(fragment.Comments.TotalCount), - CreatedAt: fragment.CreatedAt.Format(time.RFC3339), - UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), - User: &MinimalUser{ - Login: string(fragment.Author.Login), - }, - } - - for _, label := range fragment.Labels.Nodes { - m.Labels = append(m.Labels, string(label.Name)) - } - - return m -} - -// convertLegacyToMinimalIssuesResponse mirrors convertToMinimalIssuesResponse for -// the FeatureFlagIssueFields-disabled list_issues variant. -func convertLegacyToMinimalIssuesResponse(fragment LegacyIssueQueryFragment) MinimalIssuesResponse { - minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) - for _, issue := range fragment.Nodes { - minimalIssues = append(minimalIssues, legacyFragmentToMinimalIssue(issue)) - } - - return MinimalIssuesResponse{ - Issues: minimalIssues, - TotalCount: fragment.TotalCount, - PageInfo: MinimalPageInfo{ - HasNextPage: bool(fragment.PageInfo.HasNextPage), - HasPreviousPage: bool(fragment.PageInfo.HasPreviousPage), - StartCursor: string(fragment.PageInfo.StartCursor), - EndCursor: string(fragment.PageInfo.EndCursor), - }, - } -} - func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 906fa777d..85a799012 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -205,11 +205,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { IssueRead(t), SearchIssues(t), ListIssues(t), - LegacyListIssues(t), ListIssueTypes(t), ListIssueFields(t), IssueWrite(t), - LegacyIssueWrite(t), AddIssueComment(t), SubIssueWrite(t),