package github import ( "context" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // CloseIssueInput represents the input for closing an issue via the GraphQL API. // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type CloseIssueInput struct { IssueID githubv4.ID `json:"issueId"` ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"` } // IssueClosedStateReason represents the reason an issue was closed. // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type IssueClosedStateReason string // issueWriteFieldInput is a user-friendly issue field input for issue_write. // Field IDs and option IDs are resolved internally before calling the REST API. type issueWriteFieldInput struct { FieldName string Value any FieldOptionName string Delete bool } const ( IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED" ) // fetchIssueIDs retrieves issue IDs via the GraphQL API. // When duplicateOf is 0, it fetches only the main issue ID. // When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { // Build query variables common to both cases vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers } if duplicateOf == 0 { // Only fetch the main issue ID var query struct { Repository struct { Issue struct { ID githubv4.ID } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } if err := gqlClient.Query(ctx, &query, vars); err != nil { return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, "", nil } // Fetch both issue IDs in a single query var query struct { Repository struct { Issue struct { ID githubv4.ID } `graphql:"issue(number: $issueNumber)"` DuplicateIssue struct { ID githubv4.ID } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` } `graphql:"repository(owner: $owner, name: $repo)"` } // Add duplicate issue number to variables vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers if err := gqlClient.Query(ctx, &query, vars); err != nil { return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil } // getCloseStateReason converts a string state reason to the appropriate enum value func getCloseStateReason(stateReason string) IssueClosedStateReason { switch stateReason { case "not_planned": return IssueClosedStateReasonNotPlanned case "duplicate": return IssueClosedStateReasonDuplicate default: // Default to "completed" for empty or "completed" values return IssueClosedStateReasonCompleted } } // issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's // fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation. // shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat // fullDatabaseId on each concrete type; all four implement IssueFieldCommon. type issueFieldWriteMetadataNode struct { TypeName githubv4.String `graphql:"__typename"` IssueFieldText struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String DataType githubv4.String } `graphql:"... on IssueFieldText"` IssueFieldNumber struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String DataType githubv4.String } `graphql:"... on IssueFieldNumber"` IssueFieldDate struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String DataType githubv4.String } `graphql:"... on IssueFieldDate"` IssueFieldSingleSelect struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String DataType githubv4.String Options []struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String } } `graphql:"... on IssueFieldSingleSelect"` } type issueFieldWriteMetadataQuery struct { Repository struct { IssueFields struct { Nodes []issueFieldWriteMetadataNode } `graphql:"issueFields(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } // IssueFieldRef resolves the name of an issue field across its concrete types. // IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, // so we have to ask for `name` on each member. type IssueFieldRef struct { Date struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` } `graphql:"... on IssueFieldDate"` Number struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` } `graphql:"... on IssueFieldNumber"` SingleSelect struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` } `graphql:"... on IssueFieldSingleSelect"` Text struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` } `graphql:"... on IssueFieldText"` } // Name returns the populated name from whichever IssueFields union variant the field resolved to. func (r IssueFieldRef) Name() string { switch { case r.Date.Name != "": return string(r.Date.Name) case r.Number.Name != "": return string(r.Number.Name) case r.SingleSelect.Name != "": return string(r.SingleSelect.Name) case r.Text.Name != "": return string(r.Text.Name) } return "" } // FullDatabaseIDStr returns the fullDatabaseId string from whichever IssueFields union variant // the field resolved to. func (r IssueFieldRef) FullDatabaseIDStr() string { switch { case r.Date.FullDatabaseID != "": return string(r.Date.FullDatabaseID) case r.Number.FullDatabaseID != "": return string(r.Number.FullDatabaseID) case r.SingleSelect.FullDatabaseID != "": return string(r.SingleSelect.FullDatabaseID) case r.Text.FullDatabaseID != "": return string(r.Text.FullDatabaseID) } return "" } // IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union // of 4 concrete value types; each carries its own value scalar and a reference to its parent field. // The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode. type IssueFieldValueFragment struct { TypeName string `graphql:"__typename"` DateValue struct { Field IssueFieldRef Value githubv4.String } `graphql:"... on IssueFieldDateValue"` NumberValue struct { Field IssueFieldRef Value githubv4.Float `graphql:"valueNumber: value"` } `graphql:"... on IssueFieldNumberValue"` SingleSelectValue struct { Field IssueFieldRef Value githubv4.String } `graphql:"... on IssueFieldSingleSelectValue"` TextValue struct { Field IssueFieldRef Value githubv4.String } `graphql:"... on IssueFieldTextValue"` } func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, error) { issueFieldsRaw, exists := args["issue_fields"] if !exists { return nil, nil } var inputMaps []map[string]any switch v := issueFieldsRaw.(type) { case []any: for _, item := range v { itemMap, ok := item.(map[string]any) if !ok { return nil, fmt.Errorf("each issue_fields item must be an object") } inputMaps = append(inputMaps, itemMap) } case []map[string]any: inputMaps = v default: return nil, fmt.Errorf("issue_fields must be an array") } issueFields := make([]issueWriteFieldInput, 0, len(inputMaps)) for _, itemMap := range inputMaps { fieldName, err := RequiredParam[string](itemMap, "field_name") if err != nil || strings.TrimSpace(fieldName) == "" { return nil, fmt.Errorf("field_name is required for each issue_fields item") } fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name") if err != nil { return nil, err } deleteField, _ := OptionalParam[bool](itemMap, "delete") value, hasValue := itemMap["value"] if hasValue && value == nil { return nil, fmt.Errorf("value cannot be null for field %q", fieldName) } if deleteField { if hasValue || fieldOptionName != "" { return nil, fmt.Errorf("issue field %q cannot specify 'delete' together with 'value' or 'field_option_name'", fieldName) } issueFields = append(issueFields, issueWriteFieldInput{ FieldName: fieldName, Delete: true, }) continue } if hasValue && fieldOptionName != "" { return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName) } if !hasValue && fieldOptionName == "" { return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName) } issueFields = append(issueFields, issueWriteFieldInput{ FieldName: fieldName, Value: value, FieldOptionName: fieldOptionName, }) } return issueFields, nil } func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, error) { if len(issueFields) == 0 { return nil, nil, nil } ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") var query issueFieldWriteMetadataQuery vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), } if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { return nil, nil, fmt.Errorf("failed to query issue fields metadata: %w", err) } // Build name → node map, dispatching on concrete type to extract name. fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes)) for _, node := range query.Repository.IssueFields.Nodes { var name string switch string(node.TypeName) { case "IssueFieldText": name = string(node.IssueFieldText.Name) case "IssueFieldNumber": name = string(node.IssueFieldNumber.Name) case "IssueFieldDate": name = string(node.IssueFieldDate.Name) case "IssueFieldSingleSelect": name = string(node.IssueFieldSingleSelect.Name) default: continue } fieldByName[strings.ToLower(strings.TrimSpace(name))] = node } resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields)) var fieldIDsToDelete []int64 for _, fieldInput := range issueFields { node, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] if !ok { return nil, nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo) } var fullDatabaseIDStr, dataType string switch string(node.TypeName) { case "IssueFieldText": fullDatabaseIDStr = string(node.IssueFieldText.FullDatabaseID) dataType = string(node.IssueFieldText.DataType) case "IssueFieldNumber": fullDatabaseIDStr = string(node.IssueFieldNumber.FullDatabaseID) dataType = string(node.IssueFieldNumber.DataType) case "IssueFieldDate": fullDatabaseIDStr = string(node.IssueFieldDate.FullDatabaseID) dataType = string(node.IssueFieldDate.DataType) case "IssueFieldSingleSelect": fullDatabaseIDStr = string(node.IssueFieldSingleSelect.FullDatabaseID) dataType = string(node.IssueFieldSingleSelect.DataType) } fieldID := parseFullDatabaseID(fullDatabaseIDStr) if fieldID == 0 { return nil, nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName) } if fieldInput.Delete { fieldIDsToDelete = append(fieldIDsToDelete, fieldID) continue } resolvedValue := fieldInput.Value if fieldInput.FieldOptionName != "" { if !strings.EqualFold(dataType, "single_select") { return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType) } optionFound := false for _, option := range node.IssueFieldSingleSelect.Options { if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) { // REST API expects the option name, not the ID resolvedValue = string(option.Name) optionFound = true break } } if !optionFound { return nil, nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName) } } resolved = append(resolved, &github.IssueRequestFieldValue{ FieldID: fieldID, Value: resolvedValue, }) } return resolved, fieldIDsToDelete, nil } // fetchExistingIssueFieldValues retrieves the current field values for an issue // as IssueRequestFieldValue entries, ready to be merged before an update. func fetchExistingIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) ([]*github.IssueRequestFieldValue, error) { ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") var query struct { Repository struct { Issue struct { IssueFieldValues struct { Nodes []IssueFieldValueFragment } `graphql:"issueFieldValues(first: 25)"` } `graphql:"issue(number: $number)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "number": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers } if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { return nil, fmt.Errorf("failed to fetch existing issue field values: %w", err) } var result []*github.IssueRequestFieldValue for _, node := range query.Repository.Issue.IssueFieldValues.Nodes { var fieldIDStr string var value any switch node.TypeName { case "IssueFieldDateValue": fieldIDStr = node.DateValue.Field.FullDatabaseIDStr() value = string(node.DateValue.Value) case "IssueFieldNumberValue": fieldIDStr = node.NumberValue.Field.FullDatabaseIDStr() value = float64(node.NumberValue.Value) case "IssueFieldSingleSelectValue": fieldIDStr = node.SingleSelectValue.Field.FullDatabaseIDStr() value = string(node.SingleSelectValue.Value) case "IssueFieldTextValue": fieldIDStr = node.TextValue.Field.FullDatabaseIDStr() value = string(node.TextValue.Value) default: continue } fieldID := parseFullDatabaseID(fieldIDStr) if fieldID == 0 { continue } result = append(result, &github.IssueRequestFieldValue{ FieldID: fieldID, Value: value, }) } return result, nil } // mergeIssueFieldValues returns a merged slice where incoming values override existing ones // for the same field ID, and existing fields not present in incoming are preserved. // Ordering is deterministic: incoming entries first in their original order, followed by any // existing entries (in their original order) whose field IDs weren't seen in incoming. func mergeIssueFieldValues(existing, incoming []*github.IssueRequestFieldValue) []*github.IssueRequestFieldValue { seen := make(map[int64]struct{}, len(incoming)) result := make([]*github.IssueRequestFieldValue, 0, len(existing)+len(incoming)) for _, v := range incoming { seen[v.FieldID] = struct{}{} result = append(result, v) } for _, v := range existing { if _, ok := seen[v.FieldID]; ok { continue } result = append(result, v) } return result } // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment 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"` IssueFieldValues struct { Nodes []IssueFieldValueFragment } `graphql:"issueFieldValues(first: 25)"` } // Common interface for all issue query types type IssueQueryResult interface { GetIssueFragment() IssueQueryFragment GetIsPrivate() bool } type IssueQueryFragment struct { Nodes []IssueFragment `graphql:"nodes"` PageInfo struct { HasNextPage githubv4.Boolean HasPreviousPage githubv4.Boolean StartCursor githubv4.String EndCursor githubv4.String } TotalCount int } // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. type ListIssuesQueryWithSince struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. type ListIssuesQueryTypeWithLabelsWithSince struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value // field should be set per filter (the monolith resolver rejects multiple). type IssueFieldValueFilter struct { FieldName githubv4.String `json:"fieldName"` TextValue *githubv4.String `json:"textValue,omitempty"` DateValue *githubv4.String `json:"dateValue,omitempty"` NumberValue *githubv4.Float `json:"numberValue,omitempty"` SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"` } // Implement the interface for all query types func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } func (q *ListIssuesQueryTypeWithLabels) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } func (q *ListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } func (q *ListIssuesQueryWithSince) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } func getIssueQueryType(hasLabels bool, hasSince bool) any { switch { case hasLabels && hasSince: return &ListIssuesQueryTypeWithLabelsWithSince{} case hasLabels: return &ListIssuesQueryTypeWithLabels{} case hasSince: return &ListIssuesQueryWithSince{} default: return &ListIssuesQuery{} } } // 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{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: "The read operation to perform on a single issue.\n" + "Options are:\n" + "1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.\n" + "2. get_comments - Get issue comments.\n" + "3. get_sub_issues - Get sub-issues (children) of the issue.\n" + "4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" + "5. get_labels - Get labels assigned to the issue.\n", Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, }, "owner": { Type: "string", Description: "The owner of the repository", }, "repo": { Type: "string", Description: "The name of the repository", }, "issue_number": { Type: "number", Description: "The number of the issue", }, }, Required: []string{"method", "owner", "repo", "issue_number"}, } WithPagination(schema) return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_read", Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: true, }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *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 } issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), 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 GitHub graphql client", err), nil, nil } // attachIFC adds the IFC label to a successful tool result when // IFC labels are enabled. If the visibility lookup fails the // label is omitted rather than misclassifying the result. attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelRepoUserContent) switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) return attachIFC(result), nil, err case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) return attachIFC(result), nil, err case "get_parent": result, err := GetIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber) return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) } func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue", resp, body), nil } if flags.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } login := issue.GetUser().GetLogin() if login != "" { isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil } if !isSafeContent { return utils.NewToolResultError("access to issue details is restricted by lockdown mode"), nil } } } // Sanitize title/body on response if issue != nil { if issue.Title != nil { issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) } if issue.Body != nil { issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) } } minimalIssue := convertToMinimalIssue(issue) // Always drop the verbose REST IssueFieldValues; enrich with the GraphQL // field_values view and the hierarchy relationship signals instead. The // enrichment is best-effort: a failure here must never fail `get`. minimalIssue.IssueFieldValues = nil if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { gqlClient, err := deps.GetGQLClient(ctx) if err == nil { if enrichment, err := fetchIssueReadEnrichment(ctx, gqlClient, *issue.NodeID); err == nil { applyIssueReadEnrichment(ctx, &minimalIssue, enrichment, cache, flags.LockdownMode) } } } return MarshalledTextResult(minimalIssue), nil } // applyIssueReadEnrichment populates the hierarchy relationship signals (has_parent/has_children, // parent, sub_issues_summary) and field_values onto the minimal issue. In lockdown mode the parent // reference is omitted unless the parent content can be verified as safe; has_parent and the numeric // counts are structural routing signals and are always safe to surface. func applyIssueReadEnrichment(ctx context.Context, minimalIssue *MinimalIssue, enrichment *issueReadEnrichment, cache *lockdown.RepoAccessCache, lockdownMode bool) { if enrichment == nil { return } minimalIssue.FieldValues = enrichment.FieldValues minimalIssue.HasParent = ToBoolPtr(enrichment.Parent != nil) minimalIssue.HasChildren = ToBoolPtr(enrichment.SubIssuesSummary.Total > 0) if parent := enrichment.Parent; parent != nil { // Surface the parent reference only when it is safe to expose. Under lockdown an // unverified (possibly cross-repo) parent is omitted entirely, mirroring how unsafe // comments and sub-issues are filtered out. has_parent still routes an agent to // get_parent if it needs to follow up. if !lockdownMode || isSafeParentContent(ctx, cache, parent) { ref := parent.Ref minimalIssue.Parent = &ref } } if enrichment.SubIssuesSummary.Total > 0 { summary := enrichment.SubIssuesSummary minimalIssue.SubIssuesSummary = &summary } } // isSafeParentContent reports whether the parent issue reference can be exposed under lockdown mode. // It fails closed: any inability to positively verify safe content (missing cache, missing author, // unparseable repository, or a lookup error) results in the parent reference being omitted. func isSafeParentContent(ctx context.Context, cache *lockdown.RepoAccessCache, parent *issueReadParent) bool { if cache == nil || parent.AuthorLogin == "" { return false } owner, repo, ok := strings.Cut(parent.Ref.Repository, "/") if !ok || owner == "" || repo == "" { return false } safe, err := cache.IsSafeContent(ctx, parent.AuthorLogin, owner, repo) if err != nil { return false } return safe } func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, } comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) if err != nil { return nil, fmt.Errorf("failed to get issue comments: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue comments", resp, body), nil } if flags.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } filteredComments := make([]*github.IssueComment, 0, len(comments)) for _, comment := range comments { user := comment.User if user == nil { continue } login := user.GetLogin() if login == "" { continue } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil } if isSafeContent { filteredComments = append(filteredComments, comment) } } comments = filteredComments } minimalComments := make([]MinimalIssueComment, 0, len(comments)) for _, comment := range comments { minimalComments = append(minimalComments, convertToMinimalIssueComment(comment)) } return MarshalledTextResult(minimalComments), nil } func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } featureFlags := deps.GetFlags(ctx) opts := &github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list sub-issues", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list sub-issues", resp, body), nil } if featureFlags.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } filteredSubIssues := make([]*github.SubIssue, 0, len(subIssues)) for _, subIssue := range subIssues { user := subIssue.User if user == nil { continue } login := user.GetLogin() if login == "" { continue } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil } if isSafeContent { filteredSubIssues = append(filteredSubIssues, subIssue) } } subIssues = filteredSubIssues } r, err := json.Marshal(subIssues) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } // GetIssueParent returns the parent issue of the given issue, or a null // parent when the issue is not a sub-issue. It reads the GraphQL // Issue.parent field, the upward counterpart to get_sub_issues. // // The parent title is always sanitized (it may be cross-repo). Under // lockdown mode the parent is only returned when its author has push // access to the parent repo (mirroring GetIssue); otherwise it is omitted. func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) var query struct { Repository struct { Issue struct { Parent *struct { Number githubv4.Int Title githubv4.String State githubv4.String URL githubv4.String Author struct { Login githubv4.String } Repository struct { NameWithOwner githubv4.String } } } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers } if err := client.Query(ctx, &query, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil } parent := query.Repository.Issue.Parent if parent == nil { return MarshalledTextResult(map[string]any{"parent": nil}), nil } if flags.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } // Fail closed: omit the parent if anything needed for the safe-content // check is missing or unverifiable. parentAuthorLogin := string(parent.Author.Login) parentOwner, parentRepo, ok := strings.Cut(string(parent.Repository.NameWithOwner), "/") if parentAuthorLogin == "" || !ok || parentOwner == "" || parentRepo == "" { return MarshalledTextResult(map[string]any{"parent": nil}), nil } isSafeContent, err := cache.IsSafeContent(ctx, parentAuthorLogin, parentOwner, parentRepo) if err != nil || !isSafeContent { return MarshalledTextResult(map[string]any{"parent": nil}), nil } } return MarshalledTextResult(map[string]any{ "parent": map[string]any{ "number": int(parent.Number), "title": sanitize.Sanitize(string(parent.Title)), "state": string(parent.State), "url": string(parent.URL), "repository": string(parent.Repository.NameWithOwner), }, }), nil } func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { // Get current labels on the issue using GraphQL var query struct { Repository struct { Issue struct { Labels struct { Nodes []struct { ID githubv4.ID Name githubv4.String Color githubv4.String Description githubv4.String } TotalCount githubv4.Int } `graphql:"labels(first: 100)"` } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers } if err := client.Query(ctx, &query, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil } // Extract label information issueLabels := make([]map[string]any, len(query.Repository.Issue.Labels.Nodes)) for i, label := range query.Repository.Issue.Labels.Nodes { issueLabels[i] = map[string]any{ "id": fmt.Sprintf("%v", label.ID), "name": string(label.Name), "color": string(label.Color), "description": string(label.Description), } } response := map[string]any{ "labels": issueLabels, "totalCount": int(query.Repository.Issue.Labels.TotalCount), } out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(out)), nil } // ListIssueTypes creates a tool to list defined issue types for an organization or repository. // This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "The account owner of the repository or organization.", }, "repo": { Type: "string", Description: "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", }, }, Required: []string{"owner"}, }, }, []scopes.Scope{scopes.Repo, scopes.ReadOrg}, 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 := OptionalParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } if repo != "" { apiURL := fmt.Sprintf("repos/%s/%s/issue-types", owner, repo) req, err := client.NewRequest(ctx, "GET", apiURL, nil) if err != nil { return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } var issueTypes []*github.IssueType resp, err := client.Do(req, &issueTypes) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil } r, err := json.Marshal(issueTypes) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil } issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) if err != nil { return utils.NewToolResultErrorFromErr("failed to list issue types", err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil } r, err := json.Marshal(issueTypes) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } result := utils.NewToolResultText(string(r)) // Issue types are org-defined structural metadata (trusted, not // attacker-authored). They are scoped to an organization rather // than a single repo, so confidentiality is conservatively treated // as private (restricted to org members). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) return result, nil, nil }) } // AddIssueComment creates a tool to add a comment or reaction to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue or pull request"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "issue_number": { Type: "number", Description: "Issue or pull request number to comment on or react to.", }, "comment_id": { Type: "number", Description: "The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. Cannot be combined with body.", Minimum: jsonschema.Ptr(1.0), }, "body": { Type: "string", Description: "Comment content. Required unless reaction is provided.", }, "reaction": { Type: "string", Description: "Emoji reaction to add. Required unless body is provided.", Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, }, }, Required: []string{"owner", "repo", "issue_number"}, }, }, []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 } issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var commentID int64 hasCommentID := false if _, ok := args["comment_id"]; ok { commentID, err = RequiredBigInt(args, "comment_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if commentID < 1 { return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil } hasCommentID = true } body, hasBody, err := OptionalParamOK[string](args, "body") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } reactionContent, hasReaction, err := OptionalParamOK[string](args, "reaction") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if hasCommentID && hasBody { return utils.NewToolResultError("comment_id cannot be combined with body"), nil, nil } if hasCommentID && !hasReaction { return utils.NewToolResultError("comment_id can only be provided when reaction is provided"), nil, nil } if !hasBody && !hasReaction { return utils.NewToolResultError("at least one of body or reaction is required"), nil, nil } if hasBody && body == "" { return utils.NewToolResultError("body cannot be empty when provided"), nil, nil } if hasReaction && reactionContent == "" { return utils.NewToolResultError("reaction cannot be empty when provided"), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } var reactionResponse *MinimalResponse if hasReaction { if hasCommentID { comment, resp, err := client.Issues.GetComment(ctx, owner, repo, commentID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get issue comment", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() commentIssueNumber, err := issueNumberFromIssueURL(comment.GetIssueURL()) if err != nil { return utils.NewToolResultErrorFromErr("failed to determine issue number for comment", err), nil, nil } if commentIssueNumber != issueNumber { return utils.NewToolResultError(fmt.Sprintf("comment_id does not belong to issue_number %d", issueNumber)), nil, nil } reaction, resp, err := client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, reactionContent) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue comment", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() reactionResponse = &MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), } } else { reaction, resp, err := client.Reactions.CreateIssueReaction(ctx, owner, repo, issueNumber, reactionContent) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() reactionResponse = &MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()), } } } var commentResponse *MinimalResponse if hasBody { comment := &github.IssueComment{ Body: github.Ptr(body), } createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create comment", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create comment", resp, bodyBytes), nil, nil } commentResponse = &MinimalResponse{ ID: fmt.Sprintf("%d", createdComment.GetID()), URL: createdComment.GetHTMLURL(), } } var result any switch { case hasBody && hasReaction: result = map[string]MinimalResponse{ "comment": *commentResponse, "reaction": *reactionResponse, } case hasReaction: result = reactionResponse default: result = commentResponse } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil }) } func issueNumberFromIssueURL(issueURL string) (int, error) { issueNumberString := issueURL[strings.LastIndex(issueURL, "/")+1:] issueNumber, err := strconv.Atoi(issueNumberString) if err != nil { return 0, fmt.Errorf("invalid issue URL %q: %w", issueURL, err) } return issueNumber, nil } // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", Description: t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SUB_ISSUE_WRITE_USER_TITLE", "Change sub-issue"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: "The action to perform on a single sub-issue\n" + "Options are:\n" + "- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n" + "- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n" + "- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n" + "Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n", }, "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "issue_number": { Type: "number", Description: "The number of the parent issue", }, "sub_issue_id": { Type: "number", Description: "The ID of the sub-issue to add. ID is not the same as issue number", }, "replace_parent": { Type: "boolean", Description: "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", }, "after_id": { Type: "number", Description: "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", }, "before_id": { Type: "number", Description: "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", }, }, Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *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 } issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } subIssueID, err := RequiredInt(args, "sub_issue_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } replaceParent, err := OptionalParam[bool](args, "replace_parent") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } afterID, err := OptionalIntParam(args, "after_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } beforeID, err := OptionalIntParam(args, "before_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } switch strings.ToLower(method) { case "add": result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) return result, nil, err case "remove": // Call the remove sub-issue function result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) return result, nil, err case "reprioritize": // Call the reprioritize sub-issue function result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} return st } func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), ReplaceParent: github.Ptr(replaceParent), } subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add sub-issue", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add sub-issue", resp, body), nil } r, err := json.Marshal(subIssue) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), } subIssue, resp, err := client.SubIssue.Remove(ctx, owner, repo, int64(issueNumber), subIssueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove sub-issue", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove sub-issue", resp, body), nil } r, err := json.Marshal(subIssue) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, afterID int, beforeID int) (*mcp.CallToolResult, error) { // Validate that either after_id or before_id is specified, but not both if afterID == 0 && beforeID == 0 { return utils.NewToolResultError("either after_id or before_id must be specified"), nil } if afterID != 0 && beforeID != 0 { return utils.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil } subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), } if afterID != 0 { afterIDInt64 := int64(afterID) subIssueRequest.AfterID = &afterIDInt64 } if beforeID != 0 { beforeIDInt64 := int64(beforeID) subIssueRequest.BeforeID = &beforeIDInt64 } subIssue, resp, err := client.SubIssue.Reprioritize(ctx, owner, repo, int64(issueNumber), subIssueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to reprioritize sub-issue", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to reprioritize sub-issue", resp, body), nil } r, err := json.Marshal(subIssue) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } // SearchIssues creates a tool to search for issues. It is the // FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` // parameter and filters each result to the requested subset. Both this and // LegacySearchIssues register under the tool name "search_issues"; exactly one is // active for any given request thanks to mutually exclusive FeatureFlagEnable / // FeatureFlagDisable annotations. func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { st := searchIssuesTool(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacySearchIssues is the FeatureFlagFieldsParam-disabled variant of // search_issues. It exposes the original schema (no `fields` parameter) and never // filters results, so it acts as the kill switch when the flag is off. It owns // the canonical search_issues.snap; the flag-enabled variant owns // search_issues_ff_.snap. Delete this function when the flag is removed. func LegacySearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { st := searchIssuesTool(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } // searchIssuesTool builds the search_issues tool. When includeFields is true the // tool advertises the optional `fields` parameter, filters each result to the // requested subset, and emits fields telemetry. When false it is the original // tool with no fields parameter and no filtering. func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", Description: "Search query using GitHub issues search syntax", }, "owner": { Type: "string", Description: "Optional repository owner. If provided with repo, only issues for this repository are listed.", }, "repo": { Type: "string", Description: "Optional repository name. If provided with owner, only issues for this repository are listed.", }, "sort": { Type: "string", Description: "Sort field by number of matches of categories, defaults to best match", Enum: []any{ "comments", "reactions", "reactions-+1", "reactions--1", "reactions-smile", "reactions-thinking_face", "reactions-heart", "reactions-tada", "interactions", "created", "updated", }, }, "order": { Type: "string", Description: "Sort order", Enum: []any{"asc", "desc"}, }, }, Required: []string{"query"}, } if includeFields { schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", searchIssuesItemFieldEnum, ) } WithPagination(schema) return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "search_issues", Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search 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) { options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} if includeFields { fields, err := OptionalStringArrayParam(args, "fields") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } options = append(options, withFieldsFiltering(deps, "search_issues", fields)) } result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } // searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the // IFC label for a search_issues result. It looks up the visibility (and, for // private repos, collaborators) of every repository represented in the search // payload and joins the labels via ifc.LabelSearchIssues. If any per-repo // lookup fails the label is omitted to avoid misclassifying the result. func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn { return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) { if callResult == nil || callResult.IsError || result == nil { return } client, err := deps.GetClient(ctx) if err != nil { return } uniqueRepos := uniqueSearchIssuesRepos(result) visibilities := make([]bool, 0, len(uniqueRepos)) for _, r := range uniqueRepos { isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo) if err != nil { return } visibilities = append(visibilities, isPrivate) } if callResult.Meta == nil { callResult.Meta = mcp.Meta{} } callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) } } type searchIssuesRepoRef struct { owner string repo string } // uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the // search result, preserving order of first appearance and deduplicating. func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef { if result == nil { return nil } seen := make(map[string]struct{}) var out []searchIssuesRepoRef for _, issue := range result.Issues { if issue == nil { continue } owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL()) if !ok { continue } key := owner + "/" + repo if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} out = append(out, searchIssuesRepoRef{owner: owner, repo: repo}) } return out } // parseRepositoryURL extracts the owner and repo from a GitHub API repository // URL of the form https://api.github.com/repos/{owner}/{repo}. func parseRepositoryURL(repoURL string) (string, string, bool) { if repoURL == "" { return "", "", false } const marker = "/repos/" idx := strings.LastIndex(repoURL, marker) if idx < 0 { return "", "", false } parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/") if len(parts) < 2 || parts[0] == "" || parts[1] == "" { return "", "", false } return parts[0], parts[1], true } // SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query. type SearchIssueResult struct { *github.Issue FieldValues []MinimalFieldValue `json:"field_values,omitempty"` } // MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the // embedded REST response in favour of the normalized field_values populated via GraphQL enrichment. func (r SearchIssueResult) MarshalJSON() ([]byte, error) { issueBytes, err := json.Marshal(r.Issue) if err != nil { return nil, err } var m map[string]json.RawMessage if err := json.Unmarshal(issueBytes, &m); err != nil { return nil, err } delete(m, "issue_field_values") if r.FieldValues != nil { fv, err := json.Marshal(r.FieldValues) if err != nil { return nil, err } m["field_values"] = fv } return json.Marshal(m) } // SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values // per item, sourced from a single GraphQL nodes() round-trip. type SearchIssuesResponse struct { Total *int `json:"total_count,omitempty"` IncompleteResults *bool `json:"incomplete_results,omitempty"` Items []SearchIssueResult `json:"items"` } // searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve // each issue's custom field values in a single GraphQL request. type searchIssuesNodesQuery struct { Nodes []struct { Issue struct { ID githubv4.ID IssueFieldValues struct { Nodes []IssueFieldValueFragment } `graphql:"issueFieldValues(first: 25)"` } `graphql:"... on Issue"` } `graphql:"nodes(ids: $ids)"` } // fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and // returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and // an empty result set short-circuits the round-trip. func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalFieldValue, error) { ids := make([]githubv4.ID, 0, len(issues)) for _, iss := range issues { if iss == nil || iss.NodeID == nil || *iss.NodeID == "" { continue } ids = append(ids, githubv4.ID(*iss.NodeID)) } if len(ids) == 0 { return nil, nil } var q searchIssuesNodesQuery if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil { return nil, err } result := make(map[string][]MinimalFieldValue, len(q.Nodes)) for _, n := range q.Nodes { idStr, ok := n.Issue.ID.(string) if !ok || idStr == "" { continue } vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) for _, fv := range n.Issue.IssueFieldValues.Nodes { if m, ok := fragmentToMinimalFieldValue(fv); ok { vals = append(vals, m) } } result[idStr] = vals } return result, nil } // issueReadEnrichmentQuery fetches, in a single GraphQL round-trip, the custom field values, // parent reference, and sub-issue summary counts for the issues identified by their node IDs. // It powers the issue_read `get` relationship signals without adding extra round-trips. type issueReadEnrichmentQuery struct { Nodes []struct { Issue struct { ID githubv4.ID IssueFieldValues struct { Nodes []IssueFieldValueFragment } `graphql:"issueFieldValues(first: 25)"` Parent *struct { Number githubv4.Int Title githubv4.String State githubv4.String URL githubv4.String Author struct { Login githubv4.String } Repository struct { NameWithOwner githubv4.String } } SubIssuesSummary struct { Total githubv4.Int Completed githubv4.Int PercentCompleted githubv4.Int } } `graphql:"... on Issue"` } `graphql:"nodes(ids: $ids)"` } // issueReadParent is the parent reference plus the metadata needed to make a lockdown // safe-content decision about whether the (possibly cross-repo) parent title may be exposed. type issueReadParent struct { Ref MinimalIssueRef AuthorLogin string } // issueReadEnrichment is the flattened result of the issue_read `get` enrichment query. type issueReadEnrichment struct { FieldValues []MinimalFieldValue Parent *issueReadParent SubIssuesSummary MinimalSubIssuesSummary } // fetchIssueReadEnrichment runs one GraphQL nodes() query for the given issue node ID and returns // its field values, parent reference, and sub-issue summary counts. The parent title is sanitized // here because it may originate from a different repository. func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, nodeID string) (*issueReadEnrichment, error) { var q issueReadEnrichmentQuery if err := gqlClient.Query(ctx, &q, map[string]any{"ids": []githubv4.ID{githubv4.ID(nodeID)}}); err != nil { return nil, err } enrichment := &issueReadEnrichment{} for _, n := range q.Nodes { idStr, ok := n.Issue.ID.(string) if !ok || idStr != nodeID { continue } vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) for _, fv := range n.Issue.IssueFieldValues.Nodes { if m, ok := fragmentToMinimalFieldValue(fv); ok { vals = append(vals, m) } } enrichment.FieldValues = vals if p := n.Issue.Parent; p != nil { enrichment.Parent = &issueReadParent{ Ref: MinimalIssueRef{ Number: int(p.Number), Title: sanitize.Sanitize(string(p.Title)), State: string(p.State), URL: string(p.URL), Repository: string(p.Repository.NameWithOwner), }, AuthorLogin: string(p.Author.Login), } } enrichment.SubIssuesSummary = MinimalSubIssuesSummary{ Total: int(n.Issue.SubIssuesSummary.Total), Completed: int(n.Issue.SubIssuesSummary.Completed), PercentCompleted: int(n.Issue.SubIssuesSummary.PercentCompleted), } break } return enrichment, nil } // searchIssuesHandler runs the REST issues search, enriches each hit with custom field values // fetched via a single follow-up GraphQL nodes() query, and applies any post-process options // (e.g. IFC labelling). func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) { const errorPrefix = "failed to search issues" query, opts, err := prepareSearchArgs(args, "issue") if err != nil { return utils.NewToolResultError(err.Error()), nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil } result, resp, err := client.Search.Issues(ctx, query, opts) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix, err), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } var fieldValuesByID map[string][]MinimalFieldValue if len(result.Issues) > 0 { gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil } fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil } } items := make([]SearchIssueResult, 0, len(result.Issues)) for _, iss := range result.Issues { hit := SearchIssueResult{Issue: iss} if iss != nil && iss.NodeID != nil { hit.FieldValues = fieldValuesByID[*iss.NodeID] } items = append(items, hit) } response := SearchIssuesResponse{ Total: result.Total, IncompleteResults: result.IncompleteResults, Items: items, } cfg := searchConfig{} for _, opt := range options { opt(&cfg) } filtered := false var payload any = response if len(cfg.fields) > 0 { filteredItems, err := filterEachField(response.Items, cfg.fields) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil } payload = map[string]any{ "total_count": response.Total, "incomplete_results": response.IncompleteResults, "items": filteredItems, } filtered = true } r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } if cfg.fieldsTool != "" { recordFieldsUsageFor(ctx, cfg.fieldsDeps, cfg.fieldsTool, response, filtered, len(r)) } callResult := utils.NewToolResultText(string(r)) if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) } return callResult, nil } // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" // issueWriteFormParams are the parameters the issue_write MCP App form collects // and re-sends on submit. Any other parameter present on a call cannot be // represented by the form. The form collects (and prefills) every parameter in // the tool's current input schema, so hasNonFormParams against this set is a // forward-compatibility safety net: a parameter added to the schema in the // future but not yet wired into the form trips the check and bypasses the form // so the supplied value isn't silently dropped. var issueWriteFormParams = map[string]struct{}{ "method": {}, "owner": {}, "repo": {}, "title": {}, "body": {}, "issue_number": {}, "issue_fields": {}, "labels": {}, "assignees": {}, "milestone": {}, "type": {}, "state": {}, "state_reason": {}, "duplicate_of": {}, "_ui_submitted": {}, } // issueWriteAwaitingFormResult builds the "awaiting form submission" stub // returned when issue_write hands off to the MCP App form. The body is shared // by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so // agents that bail on error don't claim success or chain dependent tool calls // while the user is still interacting with the form; the host renders the UI // regardless because rendering is keyed off the tool's _meta.ui resourceUri. func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) *mcp.CallToolResult { var msg string if method == "update" { msg = fmt.Sprintf( "An interactive form has been shown to the user for editing issue #%d in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the issue was updated, "+ "and do not claim the operation succeeded. The issue has NOT been updated yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", issueNumber, owner, repo, ) } else { msg = fmt.Sprintf( "An interactive form has been shown to the user for creating a new issue in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the issue was created, "+ "and do not claim the operation succeeded. The issue has NOT been created yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", owner, repo, ) } return utils.NewToolResultAwaitingFormSubmission(msg) } // 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. func IssueWrite(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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner 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'.", }, "issue_fields": { Type: "array", 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: &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ "field_name": { Type: "string", Description: "Issue field name (case-insensitive). Must match a field " + "returned by list_issue_fields for this repository or its organization.", }, "value": { Types: []string{"string", "number", "boolean"}, 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'.", }, "field_option_name": { Type: "string", Description: "Option name for single-select fields. Validated against " + "the field's options before the API call. Cannot be combined with " + "'value' or 'delete'.", }, "delete": { Type: "boolean", Enum: []any{true}, Description: "Set to true to clear this field's current value on the " + "issue. Cannot be combined with 'value' or 'field_option_name'.", }, }, Required: []string{"field_name"}, }, }, }, 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 } // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). if shouldDeferToForm(ctx, deps, req, args, issueWriteFormParams) { issueNumber := 0 if method == "update" { n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } issueNumber = n } return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), 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 } var issueFields []issueWriteFieldInput issueFields, err = optionalIssueWriteFields(args) if err != nil { return utils.NewToolResultError(err.Error()), 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 } var issueFieldValues []*github.IssueRequestFieldValue var fieldIDsToDelete []int64 if len(issueFields) > 0 { issueFieldValues, fieldIDsToDelete, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil } } switch method { case "create": result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) 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, issueFieldValues, fieldIDsToDelete, 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} 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 } // Create the issue request issueRequest := &github.IssueRequest{ Title: github.Ptr(title), Body: github.Ptr(body), Assignees: &assignees, Labels: &labels, IssueFieldValues: issueFieldValues, } if milestoneNum != 0 { issueRequest.Milestone = &milestoneNum } if issueType != "" { issueRequest.Type = github.Ptr(issueType) } issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create issue", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create issue", resp, body), nil } // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", issue.GetID()), URL: issue.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil } return utils.NewToolResultText(string(r)), nil } // UpdateIssueOptions controls which optional fields are included in an issue update request. type UpdateIssueOptions struct { // AssigneesProvided sends the assignees field even when the slice is empty. AssigneesProvided bool // LabelsProvided sends the labels field even when the slice is empty. LabelsProvided bool } func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) { updateOptions := UpdateIssueOptions{ AssigneesProvided: len(assignees) > 0, LabelsProvided: len(labels) > 0, } for _, opt := range opts { updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided } // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} // Set optional parameters if provided if title != "" { issueRequest.Title = github.Ptr(title) } if body != "" { issueRequest.Body = github.Ptr(body) } if updateOptions.LabelsProvided { issueRequest.Labels = &labels } if updateOptions.AssigneesProvided { issueRequest.Assignees = &assignees } if milestoneNum != 0 { issueRequest.Milestone = &milestoneNum } if issueType != "" { issueRequest.Type = github.Ptr(issueType) } // Field IDs to clear via DELETE after the PATCH. See the post-PATCH loop // for why we can't just rely on REST set semantics. var fallbackDeleteFieldIDs []int64 if len(issueFieldValues) > 0 || len(fieldIDsToDelete) > 0 { // REST PATCH uses set semantics, so fetch existing values, merge in // the new ones, then drop anything explicitly deleted. existing, err := fetchExistingIssueFieldValues(ctx, gqlClient, owner, repo, issueNumber) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil } merged := mergeIssueFieldValues(existing, issueFieldValues) if len(fieldIDsToDelete) > 0 { deleteSet := make(map[int64]bool, len(fieldIDsToDelete)) for _, id := range fieldIDsToDelete { deleteSet[id] = true } kept := make([]*github.IssueRequestFieldValue, 0, len(merged)) for _, v := range merged { if !deleteSet[v.FieldID] { kept = append(kept, v) } } merged = kept } if len(merged) == 0 && len(fieldIDsToDelete) > 0 { // Only queue DELETEs for fields actually present — the endpoint // returns 404 otherwise, and "delete a field that isn't set" should // stay a no-op (callers often invoke delete:true idempotently). existingIDs := make(map[int64]bool, len(existing)) for _, e := range existing { existingIDs[e.FieldID] = true } for _, id := range fieldIDsToDelete { if existingIDs[id] { fallbackDeleteFieldIDs = append(fallbackDeleteFieldIDs, id) } } } else { issueRequest.IssueFieldValues = merged } } updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil } // Per-field DELETE fallback. The PATCH can't clear field values when the // merged set is empty — go-github's `omitempty` strips the empty slice // and the dotcom REST handler skips its issue_field_values block when the // key is absent. Errors are aggregated (not short-circuited) so callers // can see which fields succeeded and which need retry. if len(fallbackDeleteFieldIDs) > 0 { var failedIDs, succeededIDs []int64 var firstFailureErr error var firstFailureResp *github.Response for _, fieldID := range fallbackDeleteFieldIDs { path := fmt.Sprintf("repos/%s/%s/issues/%d/issue-field-values/%d", owner, repo, issueNumber, fieldID) req, err := client.NewRequest(ctx, http.MethodDelete, path, nil) if err != nil { failedIDs = append(failedIDs, fieldID) if firstFailureErr == nil { firstFailureErr = err } continue } delResp, err := client.Do(req, nil) if err != nil { failedIDs = append(failedIDs, fieldID) if firstFailureErr == nil { firstFailureErr = err firstFailureResp = delResp } continue } succeededIDs = append(succeededIDs, fieldID) _ = delResp.Body.Close() } if len(failedIDs) > 0 { msg := fmt.Sprintf("failed to clear issue field values: failed=%v, cleared=%v", failedIDs, succeededIDs) return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil } } // Use GraphQL API for state updates if state != "" { // Mandate specifying duplicateOf when trying to close as duplicate if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 { return utils.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil } // Get target issue ID (and duplicate issue ID if needed) issueID, duplicateIssueID, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, duplicateOf) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil } switch state { case "open": // Use ReopenIssue mutation for opening var mutation struct { ReopenIssue struct { Issue struct { ID githubv4.ID Number githubv4.Int URL githubv4.String State githubv4.String } } `graphql:"reopenIssue(input: $input)"` } err = gqlClient.Mutate(ctx, &mutation, githubv4.ReopenIssueInput{ IssueID: issueID, }, nil) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil } case "closed": // Use CloseIssue mutation for closing var mutation struct { CloseIssue struct { Issue struct { ID githubv4.ID Number githubv4.Int URL githubv4.String State githubv4.String } } `graphql:"closeIssue(input: $input)"` } stateReasonValue := getCloseStateReason(stateReason) closeInput := CloseIssueInput{ IssueID: issueID, StateReason: &stateReasonValue, } // Set duplicate issue ID if needed if stateReason == "duplicate" { closeInput.DuplicateIssueID = &duplicateIssueID } err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil } } } // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", updatedIssue.GetID()), URL: updatedIssue.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } // ListIssues creates a tool to list issues in a GitHub repository. It is the // FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` // parameter and filters each issue to the requested subset. Both this and // LegacyListIssues register under the tool name "list_issues"; exactly one is // active for any given request thanks to mutually exclusive FeatureFlagEnable / // FeatureFlagDisable annotations. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { st := listIssuesTool(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacyListIssues is the FeatureFlagFieldsParam-disabled variant of list_issues. // It exposes the original schema (no `fields` parameter) and never filters // results, so it acts as the kill switch when the flag is off. It owns the // canonical list_issues.snap; the flag-enabled variant owns // list_issues_ff_.snap. Delete this function when the flag is removed. func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { st := listIssuesTool(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } // listIssuesTool builds the list_issues tool. When includeFields is true the // tool advertises the optional `fields` parameter, filters each issue to the // requested subset, and emits fields telemetry. When false it is the original // tool with no fields parameter and no filtering. func listIssuesTool(t translations.TranslationHelperFunc, includeFields bool) 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)", }, "field_filters": { Type: "array", 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: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "field_name": { Type: "string", Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", }, "value": { Type: "string", 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.", }, }, Required: []string{"field_name", "value"}, }, }, }, Required: []string{"owner", "repo"}, } if includeFields { schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data.", listIssuesItemFieldEnum, ) } 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 } var fields []string if includeFields { fields, err = OptionalStringArrayParam(args, "fields") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } // Set optional parameters if provided state, err := OptionalParam[string](args, "state") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Normalize and filter by state 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} } // Get labels 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 } // Normalize and validate orderBy orderBy = strings.ToUpper(orderBy) switch orderBy { case "CREATED_AT", "UPDATED_AT", "COMMENTS": // Valid, keep as is default: orderBy = "CREATED_AT" } // Normalize and validate direction direction = strings.ToUpper(direction) switch direction { case "ASC", "DESC": // Valid, keep as is default: direction = "DESC" } since, err := OptionalParam[string](args, "since") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // There are two optional parameters: since and labels. 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 rawFilters, err := parseRawFieldFilters(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { return nil, nil, err } // Check if someone tried to use page-based pagination instead of cursor-based 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 } // Check if pagination parameters were explicitly provided _, perPageProvided := args["perPage"] paginationExplicit := perPageProvided paginationParams, err := pagination.ToGraphQLParams() if err != nil { return nil, nil, err } // Use default of 30 if pagination was not explicitly provided 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 } // Resolve field filters by looking up the repo's issue fields so we can // coerce each value into the right typed slot on IssueFieldValueFilter. fieldFilters := []IssueFieldValueFilter{} if len(rawFilters) > 0 { fields, err := fetchIssueFields(ctx, client, owner, repo) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to look up issue fields for field_filters", err), nil, nil } fieldFilters, err = resolveFieldFilters(rawFilters, fields) if err != nil { return utils.NewToolResultError(err.Error()), 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), "issueFieldValues": fieldFilters, } if paginationParams.After != nil { vars["after"] = githubv4.String(*paginationParams.After) } else { // Used within query, therefore must be set to nil and provided as $after vars["after"] = (*githubv4.String)(nil) } // Ensure optional parameters are set if hasLabels { // Use query with labels filtering - convert string labels to githubv4.String slice 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 := getIssueQueryType(hasLabels, hasSince) // The list_issues query references the issue_fields-gated IssueFieldValueFilter // input type unconditionally, so we always opt into the feature via header. This // is a no-op once the flags are globally rolled out. ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") if err := client.Query(ctxWithFeatures, 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.(IssueQueryResult); ok { resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) isPrivate = queryResult.GetIsPrivate() } filtered := false var payload any = resp if includeFields && len(fields) > 0 { filteredIssues, err := filterEachField(resp.Issues, fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter issues", err), nil, nil } payload = map[string]any{ "issues": filteredIssues, "totalCount": resp.TotalCount, "pageInfo": resp.PageInfo, } filtered = true } r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } if includeFields { recordFieldsUsageFor(ctx, deps, "list_issues", resp, filtered, len(r)) } result := utils.NewToolResultText(string(r)) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil }) return st } // rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. type rawFieldFilter struct { Name string Value string } // parseRawFieldFilters extracts the optional field_filters parameter into a list of // {name, value} pairs. The value is always a string here; type-aware coercion happens // later in resolveFieldFilters once we know each field's data_type. func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { raw, ok := args["field_filters"] if !ok { return nil, nil } var entries []map[string]any switch v := raw.(type) { case []any: for _, f := range v { entry, ok := f.(map[string]any) if !ok { return nil, fmt.Errorf("each field_filters entry must be an object") } entries = append(entries, entry) } case []map[string]any: entries = v default: return nil, fmt.Errorf("field_filters must be an array") } filters := make([]rawFieldFilter, 0, len(entries)) for _, entry := range entries { fieldName, err := RequiredParam[string](entry, "field_name") if err != nil { return nil, fmt.Errorf("field_filters entry: %s", err.Error()) } value, err := RequiredParam[string](entry, "value") if err != nil { return nil, fmt.Errorf("field_filters entry %q: %s", fieldName, err.Error()) } filters = append(filters, rawFieldFilter{Name: fieldName, Value: value}) } return filters, nil } // resolveFieldFilters matches each raw filter against a known field definition and // coerces the value into the right typed slot on IssueFieldValueFilter. Matching is // case-insensitive on field name; option names are also matched case-insensitively for // single-select fields. func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]IssueFieldValueFilter, error) { byName := make(map[string]IssueField, len(fields)) knownNames := make([]string, 0, len(fields)) for _, f := range fields { byName[strings.ToLower(f.Name)] = f knownNames = append(knownNames, f.Name) } out := make([]IssueFieldValueFilter, 0, len(rawFilters)) for _, rf := range rawFilters { field, ok := byName[strings.ToLower(rf.Name)] if !ok { return nil, fmt.Errorf("field_filters: unknown field %q. Known fields: %s", rf.Name, strings.Join(knownNames, ", ")) } filter := IssueFieldValueFilter{FieldName: githubv4.String(field.Name)} switch field.DataType { case "SINGLE_SELECT": // Validate the option name against the field's options so we fail fast // with a useful error instead of an opaque GraphQL one. var matched string for _, o := range field.Options { if strings.EqualFold(o.Name, rf.Value) { matched = o.Name break } } if matched == "" { optionNames := make([]string, 0, len(field.Options)) for _, o := range field.Options { optionNames = append(optionNames, o.Name) } return nil, fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", rf.Value, field.Name, strings.Join(optionNames, ", ")) } v := githubv4.String(matched) filter.SingleSelectOptionValue = &v case "TEXT": v := githubv4.String(rf.Value) filter.TextValue = &v case "DATE": if _, err := time.Parse("2006-01-02", rf.Value); err != nil { return nil, fmt.Errorf("field_filters: %q is not a valid date for %q (expected YYYY-MM-DD): %s", rf.Value, field.Name, err.Error()) } v := githubv4.String(rf.Value) filter.DateValue = &v case "NUMBER": n, err := strconv.ParseFloat(rf.Value, 64) if err != nil { return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) } v := githubv4.Float(n) filter.NumberValue = &v default: return nil, fmt.Errorf("field_filters: field %q has unsupported data_type %q", field.Name, field.DataType) } out = append(out, filter) } return out, nil } // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" func parseISOTimestamp(timestamp string) (time.Time, error) { if timestamp == "" { return time.Time{}, fmt.Errorf("empty timestamp") } // Try RFC3339 format (standard ISO 8601 with time) t, err := time.Parse(time.RFC3339, timestamp) if err == nil { return t, nil } // Try simple date format (YYYY-MM-DD) t, err = time.Parse("2006-01-02", timestamp) if err == nil { return t, nil } // Return error with supported formats return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) }