1703 lines
61 KiB
Go
1703 lines
61 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
|
|
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/inventory"
|
|
"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"
|
|
)
|
|
|
|
func normalizeConfidence(confidence string) string {
|
|
return strings.ToUpper(strings.TrimSpace(confidence))
|
|
}
|
|
|
|
// issueUpdateTool is a helper to create single-field issue update tools.
|
|
func issueUpdateTool(
|
|
t translations.TranslationHelperFunc,
|
|
name, description, title string,
|
|
extraProps map[string]*jsonschema.Schema,
|
|
extraRequired []string,
|
|
buildRequest func(args map[string]any) (*github.IssueRequest, error),
|
|
) inventory.ServerTool {
|
|
props := map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
}
|
|
maps.Copy(props, extraProps)
|
|
|
|
required := append([]string{"owner", "repo", "issue_number"}, extraRequired...)
|
|
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: name,
|
|
Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: props,
|
|
Required: required,
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
|
|
issueReq, err := buildRequest(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
|
|
}
|
|
|
|
issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueReq)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularCreateIssue creates a tool to create a new issue.
|
|
func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "create_issue",
|
|
Description: t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository with a title and optional body."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Create Issue"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"title": {
|
|
Type: "string",
|
|
Description: "Issue title",
|
|
},
|
|
"body": {
|
|
Type: "string",
|
|
Description: "Issue body content (optional)",
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "title"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
title, err := RequiredParam[string](args, "title")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
body, _ := OptionalParam[string](args, "body")
|
|
|
|
issueReq := &github.IssueRequest{
|
|
Title: &title,
|
|
}
|
|
if body != "" {
|
|
issueReq.Body = &body
|
|
}
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
issue, resp, err := client.Issues.Create(ctx, owner, repo, issueReq)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularUpdateIssueTitle creates a tool to update an issue's title.
|
|
func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return issueUpdateTool(t,
|
|
"update_issue_title",
|
|
"Update the title of an existing issue.",
|
|
"Update Issue Title",
|
|
map[string]*jsonschema.Schema{
|
|
"title": {Type: "string", Description: "The new title for the issue"},
|
|
},
|
|
[]string{"title"},
|
|
func(args map[string]any) (*github.IssueRequest, error) {
|
|
title, err := RequiredParam[string](args, "title")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &github.IssueRequest{Title: &title}, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// GranularUpdateIssueBody creates a tool to update an issue's body.
|
|
func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return issueUpdateTool(t,
|
|
"update_issue_body",
|
|
"Update the body content of an existing issue.",
|
|
"Update Issue Body",
|
|
map[string]*jsonschema.Schema{
|
|
"body": {Type: "string", Description: "The new body content for the issue"},
|
|
},
|
|
[]string{"body"},
|
|
func(args map[string]any) (*github.IssueRequest, error) {
|
|
body, err := RequiredParam[string](args, "body")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &github.IssueRequest{Body: &body}, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// GranularUpdateIssueAssignees creates a tool to update an issue's assignees.
|
|
func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "update_issue_assignees",
|
|
Description: t("TOOL_UPDATE_ISSUE_ASSIGNEES_DESCRIPTION", "Update the assignees of an existing issue. This replaces the current assignees with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_UPDATE_ISSUE_ASSIGNEES_USER_TITLE", "Update Issue Assignees"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"assignees": {
|
|
Type: "array",
|
|
Description: "GitHub usernames to assign to this issue.",
|
|
Items: &jsonschema.Schema{
|
|
OneOf: []*jsonschema.Schema{
|
|
{Type: "string", Description: "GitHub username"},
|
|
{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"login": {
|
|
Type: "string",
|
|
Description: "GitHub username",
|
|
},
|
|
"rationale": {
|
|
Type: "string",
|
|
Description: "One concise sentence explaining what specifically about the issue led you to choose this assignee. " +
|
|
"State the concrete signal (e.g. 'Authored the file the crash originates in').",
|
|
MaxLength: jsonschema.Ptr(280),
|
|
},
|
|
"confidence": {
|
|
Type: "string",
|
|
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
|
|
Enum: []any{"LOW", "MEDIUM", "HIGH"},
|
|
},
|
|
"is_suggestion": {
|
|
Type: "boolean",
|
|
Description: "If true, this assignee is sent to the API as a suggestion (suggest:true) rather than an applied assignee. " +
|
|
"Whether the assignee is applied or recorded as a proposal is determined by the API.",
|
|
},
|
|
},
|
|
Required: []string{"login"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "assignees"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
|
|
assigneesRaw, ok := args["assignees"]
|
|
if !ok {
|
|
return utils.NewToolResultError("missing required parameter: assignees"), nil, nil
|
|
}
|
|
assigneesSlice, ok := assigneesRaw.([]any)
|
|
if !ok {
|
|
// Also accept []string for callers that pre-typed the array.
|
|
if strs, ok := assigneesRaw.([]string); ok {
|
|
assigneesSlice = make([]any, len(strs))
|
|
for i, s := range strs {
|
|
assigneesSlice[i] = s
|
|
}
|
|
} else {
|
|
return utils.NewToolResultError("parameter assignees must be an array"), nil, nil
|
|
}
|
|
}
|
|
|
|
useObjectForm := false
|
|
payload := make([]any, 0, len(assigneesSlice))
|
|
for _, item := range assigneesSlice {
|
|
switch v := item.(type) {
|
|
case string:
|
|
payload = append(payload, v)
|
|
case map[string]any:
|
|
login, err := RequiredParam[string](v, "login")
|
|
if err != nil {
|
|
return utils.NewToolResultError("each assignee object must have a 'login' string"), nil, nil
|
|
}
|
|
rationale, err := OptionalParam[string](v, "rationale")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale = strings.TrimSpace(rationale)
|
|
if len([]rune(rationale)) > 280 {
|
|
return utils.NewToolResultError("assignee rationale must be 280 characters or less"), nil, nil
|
|
}
|
|
confidence, err := OptionalParam[string](v, "confidence")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
confidence = normalizeConfidence(confidence)
|
|
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
|
|
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
|
|
}
|
|
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
if rationale == "" && !isSuggestion && confidence == "" {
|
|
payload = append(payload, login)
|
|
} else {
|
|
useObjectForm = true
|
|
payload = append(payload, assigneeWithIntent{Login: login, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion})
|
|
}
|
|
default:
|
|
return utils.NewToolResultError("each assignee must be a string or an object with 'login' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil
|
|
}
|
|
}
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
var body any
|
|
if useObjectForm {
|
|
body = &assigneesUpdateRequest{Assignees: payload}
|
|
} else {
|
|
// Preserve the standard wire format when no rationale or suggest is supplied.
|
|
logins := make([]string, len(payload))
|
|
for i, p := range payload {
|
|
logins[i] = p.(string)
|
|
}
|
|
body = &github.IssueRequest{Assignees: &logins}
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
|
|
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
|
|
}
|
|
|
|
issue := &github.Issue{}
|
|
resp, err := client.Do(req, issue)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// labelWithIntent represents the object form of a label entry, allowing a
|
|
// rationale, confidence level, and/or suggest flag to be sent alongside the label name.
|
|
type labelWithIntent struct {
|
|
Name string `json:"name"`
|
|
Rationale string `json:"rationale,omitempty"`
|
|
Confidence string `json:"confidence,omitempty"`
|
|
Suggest bool `json:"suggest,omitempty"`
|
|
}
|
|
|
|
// labelsUpdateRequest is a custom request body for updating an issue's labels
|
|
// where individual labels may optionally include a rationale. Each element of
|
|
// Labels is either a string (label name) or a labelWithIntent object.
|
|
type labelsUpdateRequest struct {
|
|
Labels []any `json:"labels"`
|
|
}
|
|
|
|
// assigneeWithIntent represents the object form of an assignee entry, allowing a
|
|
// rationale, confidence level, and/or suggest flag to be sent alongside the login.
|
|
type assigneeWithIntent struct {
|
|
Login string `json:"login"`
|
|
Rationale string `json:"rationale,omitempty"`
|
|
Confidence string `json:"confidence,omitempty"`
|
|
Suggest bool `json:"suggest,omitempty"`
|
|
}
|
|
|
|
// assigneesUpdateRequest is a custom request body for updating an issue's
|
|
// assignees where individual assignees may optionally include a rationale. Each
|
|
// element of Assignees is either a string (login) or an assigneeWithIntent object.
|
|
type assigneesUpdateRequest struct {
|
|
Assignees []any `json:"assignees"`
|
|
}
|
|
|
|
// GranularUpdateIssueLabels creates a tool to update an issue's labels.
|
|
func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "update_issue_labels",
|
|
Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"labels": {
|
|
Type: "array",
|
|
Description: "Labels to apply to this issue.",
|
|
Items: &jsonschema.Schema{
|
|
OneOf: []*jsonschema.Schema{
|
|
{Type: "string", Description: "Label name"},
|
|
{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"name": {
|
|
Type: "string",
|
|
Description: "Label name",
|
|
},
|
|
"rationale": {
|
|
Type: "string",
|
|
Description: "One concise sentence explaining what specifically about the issue led you to choose this label. " +
|
|
"State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
|
|
MaxLength: jsonschema.Ptr(280),
|
|
},
|
|
"confidence": {
|
|
Type: "string",
|
|
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
|
|
Enum: []any{"LOW", "MEDIUM", "HIGH"},
|
|
},
|
|
"is_suggestion": {
|
|
Type: "boolean",
|
|
Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " +
|
|
"Whether the label is applied or recorded as a proposal is determined by the API.",
|
|
},
|
|
},
|
|
Required: []string{"name"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "labels"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
|
|
labelsRaw, ok := args["labels"]
|
|
if !ok {
|
|
return utils.NewToolResultError("missing required parameter: labels"), nil, nil
|
|
}
|
|
labelsSlice, ok := labelsRaw.([]any)
|
|
if !ok {
|
|
// Also accept []string for callers that pre-typed the array.
|
|
if strs, ok := labelsRaw.([]string); ok {
|
|
labelsSlice = make([]any, len(strs))
|
|
for i, s := range strs {
|
|
labelsSlice[i] = s
|
|
}
|
|
} else {
|
|
return utils.NewToolResultError("parameter labels must be an array"), nil, nil
|
|
}
|
|
}
|
|
|
|
useObjectForm := false
|
|
payload := make([]any, 0, len(labelsSlice))
|
|
for _, item := range labelsSlice {
|
|
switch v := item.(type) {
|
|
case string:
|
|
payload = append(payload, v)
|
|
case map[string]any:
|
|
name, err := RequiredParam[string](v, "name")
|
|
if err != nil {
|
|
return utils.NewToolResultError("each label object must have a 'name' string"), nil, nil
|
|
}
|
|
rationale, err := OptionalParam[string](v, "rationale")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale = strings.TrimSpace(rationale)
|
|
if len([]rune(rationale)) > 280 {
|
|
return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil
|
|
}
|
|
confidence, err := OptionalParam[string](v, "confidence")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
confidence = normalizeConfidence(confidence)
|
|
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
|
|
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
|
|
}
|
|
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
if rationale == "" && !isSuggestion && confidence == "" {
|
|
payload = append(payload, name)
|
|
} else {
|
|
useObjectForm = true
|
|
payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion})
|
|
}
|
|
default:
|
|
return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil
|
|
}
|
|
}
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
var body any
|
|
if useObjectForm {
|
|
body = &labelsUpdateRequest{Labels: payload}
|
|
} else {
|
|
// Preserve the standard wire format when no rationale or suggest is supplied.
|
|
names := make([]string, len(payload))
|
|
for i, p := range payload {
|
|
names[i] = p.(string)
|
|
}
|
|
body = &github.IssueRequest{Labels: &names}
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
|
|
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
|
|
}
|
|
|
|
issue := &github.Issue{}
|
|
resp, err := client.Do(req, issue)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularUpdateIssueMilestone creates a tool to update an issue's milestone.
|
|
func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return issueUpdateTool(t,
|
|
"update_issue_milestone",
|
|
"Update the milestone of an existing issue.",
|
|
"Update Issue Milestone",
|
|
map[string]*jsonschema.Schema{
|
|
"milestone": {
|
|
Type: "integer",
|
|
Description: "The milestone number to set on the issue",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
},
|
|
[]string{"milestone"},
|
|
func(args map[string]any) (*github.IssueRequest, error) {
|
|
milestone, err := RequiredInt(args, "milestone")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &github.IssueRequest{Milestone: &milestone}, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// issueTypeWithIntent represents the object form of the issue type field,
|
|
// allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name.
|
|
type issueTypeWithIntent struct {
|
|
Value string `json:"value"`
|
|
Rationale string `json:"rationale,omitempty"`
|
|
Confidence string `json:"confidence,omitempty"`
|
|
Suggest bool `json:"suggest,omitempty"`
|
|
}
|
|
|
|
// issueTypeUpdateRequest is a custom request body for updating an issue type
|
|
// with optional intent metadata, using the object form that the REST API accepts.
|
|
type issueTypeUpdateRequest struct {
|
|
Type issueTypeWithIntent `json:"type"`
|
|
}
|
|
|
|
// GranularUpdateIssueType creates a tool to update an issue's type.
|
|
func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "update_issue_type",
|
|
Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"issue_type": {
|
|
Type: "string",
|
|
Description: "The issue type to set",
|
|
},
|
|
"rationale": {
|
|
Type: "string",
|
|
Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " +
|
|
"State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
|
|
MaxLength: jsonschema.Ptr(280),
|
|
},
|
|
"confidence": {
|
|
Type: "string",
|
|
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
|
|
Enum: []any{"LOW", "MEDIUM", "HIGH"},
|
|
},
|
|
"is_suggestion": {
|
|
Type: "boolean",
|
|
Description: "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
|
|
"Whether the type is applied or recorded as a proposal is determined by the API.",
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "issue_type"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
issueType, err := RequiredParam[string](args, "issue_type")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale, err := OptionalParam[string](args, "rationale")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale = strings.TrimSpace(rationale)
|
|
if len([]rune(rationale)) > 280 {
|
|
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
|
|
}
|
|
confidence, err := OptionalParam[string](args, "confidence")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
confidence = normalizeConfidence(confidence)
|
|
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
|
|
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
|
|
}
|
|
isSuggestion, err := OptionalParam[bool](args, "is_suggestion")
|
|
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
|
|
}
|
|
|
|
var body any
|
|
if rationale != "" || isSuggestion || confidence != "" {
|
|
body = &issueTypeUpdateRequest{
|
|
Type: issueTypeWithIntent{
|
|
Value: issueType,
|
|
Rationale: rationale,
|
|
Confidence: confidence,
|
|
Suggest: isSuggestion,
|
|
},
|
|
}
|
|
} else {
|
|
body = &github.IssueRequest{Type: &issueType}
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
|
|
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
|
|
}
|
|
|
|
issue := &github.Issue{}
|
|
resp, err := client.Do(req, issue)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// stateWithIntent represents the object form of the state field, allowing
|
|
// rationale, confidence, and/or suggest flag to be sent alongside the state value.
|
|
type stateWithIntent struct {
|
|
Value string `json:"value"`
|
|
Rationale string `json:"rationale,omitempty"`
|
|
Confidence string `json:"confidence,omitempty"`
|
|
Suggest bool `json:"suggest,omitempty"`
|
|
}
|
|
|
|
// stateUpdateRequest is a custom request body for updating an issue's state
|
|
// with optional intent metadata, using the object form that the REST API accepts.
|
|
type stateUpdateRequest struct {
|
|
State stateWithIntent `json:"state"`
|
|
StateReason string `json:"state_reason,omitempty"`
|
|
DuplicateIssueID *int64 `json:"duplicate_issue_id,omitempty"`
|
|
}
|
|
|
|
// GranularUpdateIssueState creates a tool to update an issue's state.
|
|
func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "update_issue_state",
|
|
Description: t("TOOL_UPDATE_ISSUE_STATE_DESCRIPTION", "Update the state of an existing issue (open or closed), with an optional state reason. When closing, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the decision. Use is_suggestion to propose the change without applying it directly."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_UPDATE_ISSUE_STATE_USER_TITLE", "Update Issue State"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"state": {
|
|
Type: "string",
|
|
Description: "The new state for the issue",
|
|
Enum: []any{"open", "closed"},
|
|
},
|
|
"state_reason": {
|
|
Type: "string",
|
|
Description: "The reason for the state change (only for closed state)",
|
|
Enum: []any{"completed", "not_planned", "duplicate"},
|
|
},
|
|
"rationale": {
|
|
Type: "string",
|
|
Description: "One concise sentence explaining what specifically about the issue led you to choose this state. " +
|
|
"State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).",
|
|
MaxLength: jsonschema.Ptr(280),
|
|
},
|
|
"confidence": {
|
|
Type: "string",
|
|
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
|
|
Enum: []any{"LOW", "MEDIUM", "HIGH"},
|
|
},
|
|
"is_suggestion": {
|
|
Type: "boolean",
|
|
Description: "If true, this state change is sent to the API as a suggestion (suggest:true) rather than an applied change. " +
|
|
"Whether the change is applied or recorded as a proposal is determined by the API.",
|
|
},
|
|
"duplicate_of": {
|
|
Type: "number",
|
|
Description: "The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API.",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "state"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
state, err := RequiredParam[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
|
|
}
|
|
rationale, err := OptionalParam[string](args, "rationale")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale = strings.TrimSpace(rationale)
|
|
if len([]rune(rationale)) > 280 {
|
|
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
|
|
}
|
|
confidence, err := OptionalParam[string](args, "confidence")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
confidence = normalizeConfidence(confidence)
|
|
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
|
|
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
|
|
}
|
|
isSuggestion, err := OptionalParam[bool](args, "is_suggestion")
|
|
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 stateReason != "" && state != "closed" {
|
|
return utils.NewToolResultError("state_reason can only be used when state is 'closed'"), nil, nil
|
|
}
|
|
if duplicateOf != 0 && stateReason != "duplicate" {
|
|
return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil
|
|
}
|
|
if isSuggestion && stateReason == "duplicate" && duplicateOf == 0 {
|
|
return utils.NewToolResultError("duplicate_of is required when suggesting a close as duplicate"), nil, nil
|
|
}
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
var body any
|
|
if rationale != "" || isSuggestion || confidence != "" || duplicateOf != 0 {
|
|
req := &stateUpdateRequest{
|
|
State: stateWithIntent{
|
|
Value: state,
|
|
Rationale: rationale,
|
|
Confidence: confidence,
|
|
Suggest: isSuggestion,
|
|
},
|
|
StateReason: stateReason,
|
|
}
|
|
if duplicateOf != 0 {
|
|
duplicateIssue, resp, err := client.Issues.Get(ctx, owner, repo, duplicateOf)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get duplicate issue", resp, err), nil, nil
|
|
}
|
|
_ = resp.Body.Close()
|
|
id := duplicateIssue.GetID()
|
|
req.DuplicateIssueID = &id
|
|
}
|
|
body = req
|
|
} else {
|
|
req := &github.IssueRequest{State: &state}
|
|
if stateReason != "" {
|
|
req.StateReason = &stateReason
|
|
}
|
|
body = req
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
|
|
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
|
|
}
|
|
|
|
issue := &github.Issue{}
|
|
resp, err := client.Do(req, issue)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", issue.GetID()),
|
|
URL: issue.GetHTMLURL(),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularAddSubIssue creates a tool to add a sub-issue.
|
|
func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "add_sub_issue",
|
|
Description: t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add Sub-Issue"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The parent issue number",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"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: "If true, reparent the sub-issue if it already has a parent",
|
|
},
|
|
},
|
|
Required: []string{"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) {
|
|
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, _ := OptionalParam[bool](args, "replace_parent")
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent)
|
|
return result, nil, err
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularRemoveSubIssue creates a tool to remove a sub-issue.
|
|
func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "remove_sub_issue",
|
|
Description: t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove Sub-Issue"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(true),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The parent issue number",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"sub_issue_id": {
|
|
Type: "number",
|
|
Description: "The ID of the sub-issue to remove. ID is not the same as issue number",
|
|
},
|
|
},
|
|
Required: []string{"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) {
|
|
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
|
|
}
|
|
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID)
|
|
return result, nil, err
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularReprioritizeSubIssue creates a tool to reorder a sub-issue.
|
|
func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "reprioritize_sub_issue",
|
|
Description: t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize (reorder) a sub-issue relative to other sub-issues."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize Sub-Issue"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The parent issue number",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"sub_issue_id": {
|
|
Type: "number",
|
|
Description: "The ID of the sub-issue to reorder. ID is not the same as issue number",
|
|
},
|
|
"after_id": {
|
|
Type: "number",
|
|
Description: "The ID of the sub-issue to place this after (either after_id OR before_id should be specified)",
|
|
},
|
|
"before_id": {
|
|
Type: "number",
|
|
Description: "The ID of the sub-issue to place this before (either after_id OR before_id should be specified)",
|
|
},
|
|
},
|
|
Required: []string{"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) {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID)
|
|
return result, nil, err
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// SetIssueFieldValueInput represents the input for the setIssueFieldValue GraphQL mutation.
|
|
type SetIssueFieldValueInput struct {
|
|
IssueID githubv4.ID `json:"issueId"`
|
|
IssueFields []IssueFieldCreateOrUpdateInput `json:"issueFields"`
|
|
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
|
|
}
|
|
|
|
// IssueFieldCreateOrUpdateInput represents a single field value to set on an issue.
|
|
type IssueFieldCreateOrUpdateInput struct {
|
|
FieldID githubv4.ID `json:"fieldId"`
|
|
TextValue *githubv4.String `json:"textValue,omitempty"`
|
|
NumberValue *githubv4.Float `json:"numberValue,omitempty"`
|
|
DateValue *githubv4.String `json:"dateValue,omitempty"`
|
|
SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"`
|
|
Delete *githubv4.Boolean `json:"delete,omitempty"`
|
|
Rationale *githubv4.String `json:"rationale,omitempty"`
|
|
Confidence *string `json:"confidence,omitempty"`
|
|
Suggest *githubv4.Boolean `json:"suggest,omitempty"`
|
|
}
|
|
|
|
// GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL.
|
|
func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "set_issue_fields",
|
|
Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number to update",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"fields": {
|
|
Type: "array",
|
|
Description: "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.",
|
|
MinItems: jsonschema.Ptr(1),
|
|
Items: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"field_id": {
|
|
Type: "string",
|
|
Description: "The GraphQL node ID of the issue field",
|
|
},
|
|
"text_value": {
|
|
Type: "string",
|
|
Description: "The value to set for a text field",
|
|
},
|
|
"number_value": {
|
|
Type: "number",
|
|
Description: "The value to set for a number field",
|
|
},
|
|
"date_value": {
|
|
Type: "string",
|
|
Description: "The value to set for a date field (ISO 8601 date string)",
|
|
},
|
|
"single_select_option_id": {
|
|
Type: "string",
|
|
Description: "The GraphQL node ID of the option to set for a single select field",
|
|
},
|
|
"delete": {
|
|
Type: "boolean",
|
|
Description: "Set to true to delete this field value",
|
|
},
|
|
"rationale": {
|
|
Type: "string",
|
|
Description: "One concise sentence explaining what specifically about the issue led you to choose this field value. " +
|
|
"State the concrete signal (e.g. 'Reports a crash when saving' → high priority).",
|
|
MaxLength: jsonschema.Ptr(280),
|
|
},
|
|
"confidence": {
|
|
Type: "string",
|
|
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
|
|
Enum: []any{"LOW", "MEDIUM", "HIGH"},
|
|
},
|
|
"is_suggestion": {
|
|
Type: "boolean",
|
|
Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
|
|
"Whether the value is applied or recorded as a proposal is determined by the API.",
|
|
},
|
|
},
|
|
Required: []string{"field_id"},
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "fields"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
|
|
fieldsRaw, ok := args["fields"]
|
|
if !ok {
|
|
return utils.NewToolResultError("missing required parameter: fields"), nil, nil
|
|
}
|
|
|
|
// Accept both []any and []map[string]any input forms
|
|
var fieldMaps []map[string]any
|
|
switch v := fieldsRaw.(type) {
|
|
case []any:
|
|
for _, f := range v {
|
|
fieldMap, ok := f.(map[string]any)
|
|
if !ok {
|
|
return utils.NewToolResultError("each field must be an object with 'field_id' and a value"), nil, nil
|
|
}
|
|
fieldMaps = append(fieldMaps, fieldMap)
|
|
}
|
|
case []map[string]any:
|
|
fieldMaps = v
|
|
default:
|
|
return utils.NewToolResultError("invalid parameter: fields must be an array"), nil, nil
|
|
}
|
|
if len(fieldMaps) == 0 {
|
|
return utils.NewToolResultError("fields array must not be empty"), nil, nil
|
|
}
|
|
|
|
issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps))
|
|
for _, fieldMap := range fieldMaps {
|
|
fieldID, err := RequiredParam[string](fieldMap, "field_id")
|
|
if err != nil {
|
|
return utils.NewToolResultError("field_id is required and must be a string"), nil, nil
|
|
}
|
|
|
|
input := IssueFieldCreateOrUpdateInput{
|
|
FieldID: githubv4.ID(fieldID),
|
|
}
|
|
|
|
// Count how many value keys are present; exactly one is required.
|
|
valueCount := 0
|
|
|
|
if v, err := OptionalParam[string](fieldMap, "text_value"); err == nil && v != "" {
|
|
input.TextValue = githubv4.NewString(githubv4.String(v))
|
|
valueCount++
|
|
}
|
|
if v, err := OptionalParam[float64](fieldMap, "number_value"); err == nil {
|
|
if _, exists := fieldMap["number_value"]; exists {
|
|
gqlFloat := githubv4.Float(v)
|
|
input.NumberValue = &gqlFloat
|
|
valueCount++
|
|
}
|
|
}
|
|
if v, err := OptionalParam[string](fieldMap, "date_value"); err == nil && v != "" {
|
|
input.DateValue = githubv4.NewString(githubv4.String(v))
|
|
valueCount++
|
|
}
|
|
if v, err := OptionalParam[string](fieldMap, "single_select_option_id"); err == nil && v != "" {
|
|
optionID := githubv4.ID(v)
|
|
input.SingleSelectOptionID = &optionID
|
|
valueCount++
|
|
}
|
|
if _, exists := fieldMap["delete"]; exists {
|
|
del, err := OptionalParam[bool](fieldMap, "delete")
|
|
if err == nil && del {
|
|
deleteVal := githubv4.Boolean(true)
|
|
input.Delete = &deleteVal
|
|
valueCount++
|
|
}
|
|
}
|
|
|
|
if valueCount == 0 {
|
|
return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true"), nil, nil
|
|
}
|
|
if valueCount > 1 {
|
|
return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil
|
|
}
|
|
|
|
if _, exists := fieldMap["rationale"]; exists {
|
|
rationale, err := OptionalParam[string](fieldMap, "rationale")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
rationale = strings.TrimSpace(rationale)
|
|
if len([]rune(rationale)) > 280 {
|
|
return utils.NewToolResultError("field rationale must be 280 characters or less"), nil, nil
|
|
}
|
|
if rationale != "" {
|
|
input.Rationale = githubv4.NewString(githubv4.String(rationale))
|
|
}
|
|
}
|
|
|
|
confidence, err := OptionalParam[string](fieldMap, "confidence")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
confidence = normalizeConfidence(confidence)
|
|
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
|
|
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
|
|
}
|
|
if confidence != "" {
|
|
input.Confidence = &confidence
|
|
}
|
|
|
|
isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
if isSuggestion {
|
|
suggestVal := githubv4.Boolean(true)
|
|
input.Suggest = &suggestVal
|
|
}
|
|
|
|
issueFields = append(issueFields, input)
|
|
}
|
|
|
|
gqlClient, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
|
|
}
|
|
|
|
// Resolve issue node ID
|
|
issueID, _, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, 0)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil
|
|
}
|
|
|
|
// Execute the setIssueFieldValue mutation
|
|
var mutation struct {
|
|
SetIssueFieldValue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
IssueFieldValues []struct {
|
|
TextValue struct {
|
|
Value string
|
|
} `graphql:"... on IssueFieldTextValue"`
|
|
SingleSelectValue struct {
|
|
Name string
|
|
} `graphql:"... on IssueFieldSingleSelectValue"`
|
|
DateValue struct {
|
|
Value string
|
|
} `graphql:"... on IssueFieldDateValue"`
|
|
NumberValue struct {
|
|
Value float64
|
|
} `graphql:"... on IssueFieldNumberValue"`
|
|
}
|
|
} `graphql:"setIssueFieldValue(input: $input)"`
|
|
}
|
|
|
|
mutationInput := SetIssueFieldValueInput{
|
|
IssueID: issueID,
|
|
IssueFields: issueFields,
|
|
}
|
|
|
|
// The rationale and suggest input fields on IssueFieldCreateOrUpdateInput
|
|
// are gated behind the update_issue_suggestions GraphQL feature flag.
|
|
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions")
|
|
if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil {
|
|
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil
|
|
}
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID),
|
|
URL: string(mutation.SetIssueFieldValue.Issue.URL),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularAddIssueReaction adds a reaction to an issue or pull request.
|
|
func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "add_issue_reaction",
|
|
Description: t("TOOL_ADD_ISSUE_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_ADD_ISSUE_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"issue_number": {
|
|
Type: "number",
|
|
Description: "The issue number",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"content": {
|
|
Type: "string",
|
|
Description: "The emoji reaction type",
|
|
Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"},
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "issue_number", "content"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
content, err := RequiredParam[string](args, "content")
|
|
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
|
|
}
|
|
|
|
reaction, resp, err := client.Reactions.CreateIssueReaction(ctx, owner, repo, issueNumber, content)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%d", reaction.GetID()),
|
|
URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|
|
|
|
// GranularAddIssueCommentReaction adds a reaction to an issue or pull request comment.
|
|
func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "add_issue_comment_reaction",
|
|
Description: t("TOOL_ADD_ISSUE_COMMENT_REACTION_DESCRIPTION", "Add a reaction to an issue or pull request comment."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_ADD_ISSUE_COMMENT_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request Comment"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(false),
|
|
OpenWorldHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (username or organization)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"comment_id": {
|
|
Type: "number",
|
|
Description: "The issue or pull request comment ID",
|
|
Minimum: jsonschema.Ptr(1.0),
|
|
},
|
|
"content": {
|
|
Type: "string",
|
|
Description: "The emoji reaction type",
|
|
Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"},
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "comment_id", "content"},
|
|
},
|
|
},
|
|
[]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
|
|
}
|
|
commentID, err := RequiredBigInt(args, "comment_id")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
content, err := RequiredParam[string](args, "content")
|
|
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
|
|
}
|
|
|
|
reaction, resp, err := client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, content)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to issue comment", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
r, err := json.Marshal(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()),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
},
|
|
)
|
|
st.FeatureFlagEnable = FeatureFlagIssuesGranular
|
|
return st
|
|
}
|