-
Notifications
You must be signed in to change notification settings - Fork 5k
Expand file tree
/
Copy pathfind_duplicate.go
More file actions
183 lines (169 loc) · 6.99 KB
/
Copy pathfind_duplicate.go
File metadata and controls
183 lines (169 loc) · 6.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
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/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// rankedSimilarIssue is a single "Ranked Similar Issue" element returned by the
// semantic-similarity endpoint. Only the issue fields the tool surfaces are
// decoded, and Score is nullable because the API may omit a similarity score.
type rankedSimilarIssue struct {
Issue *struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
} `json:"issue"`
Score *float64 `json:"score"`
Confidence string `json:"confidence"`
LikelyDuplicate bool `json:"likely_duplicate"`
}
// duplicateCandidate is the trimmed output for a ranked duplicate candidate,
// carrying only what an agent needs to explain and act on it.
type duplicateCandidate struct {
Issue MinimalIssueRef `json:"issue"`
Score *float64 `json:"score"`
Confidence string `json:"confidence"`
LikelyDuplicate bool `json:"likely_duplicate"`
}
// FindDuplicate creates a read-only tool that returns ranked duplicate
// candidates for an existing issue. It is a separate, feature-flagged tool so
// duplicate detection is only advertised when explicitly opted in, keeping the
// default tool surface small. The semantic ranking itself is owned by the API;
// this tool only forwards the request and projects the ranked results.
func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"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 existing issue to find duplicates for",
},
"confidence_threshold": {
Type: "number",
Description: "Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced.",
},
},
Required: []string{"owner", "repo", "issue_number"},
}
WithPagination(schema)
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "find_duplicate",
Description: t("TOOL_FIND_DUPLICATE_DESCRIPTION", "Find likely duplicate issues for an existing issue in a GitHub repository. This is a read-only search scoped to the source issue's repository: it returns ranked candidate issues with a similarity score and confidence, and does not close, link, comment on, or otherwise modify any issue."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
scopes.PublicRead(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
}
// Build the query preserving whether each optional value was supplied
// so unset parameters fall back to the API's own defaults.
query := url.Values{}
if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
} else if ok {
query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64))
}
if _, ok := args["perPage"]; ok {
perPage, err := OptionalIntParam(args, "perPage")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
query.Set("per_page", strconv.Itoa(perPage))
}
if _, ok := args["page"]; ok {
page, err := OptionalIntParam(args, "page")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
query.Set("page", strconv.Itoa(page))
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d/semantically_similar", owner, repo, issueNumber)
if encoded := query.Encode(); encoded != "" {
apiURL += "?" + encoded
}
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
}
var results []rankedSimilarIssue
resp, err := client.Do(req, &results)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to find duplicate issues", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
candidates := make([]duplicateCandidate, 0, len(results))
for _, res := range results {
// A bare issue (no ranking metadata) means ranked duplicate
// detection is not enabled for this caller; fail clearly rather
// than returning incomplete candidates.
if res.Confidence == "" || res.Issue == nil {
return utils.NewToolResultError("ranked duplicate detection is unavailable: the semantic-similarity endpoint returned issues without ranking metadata (the server-side duplicate-ranking feature is not enabled for this caller or repository)"), nil, nil
}
candidates = append(candidates, duplicateCandidate{
// Candidates are always scoped to the requested repository, so the
// ref's repository field is left empty as it was before.
Issue: newMinimalIssueRef(
res.Issue.Number,
res.Issue.Title,
res.Issue.State,
res.Issue.HTMLURL,
"",
),
Score: res.Score,
Confidence: res.Confidence,
LikelyDuplicate: res.LikelyDuplicate,
})
}
r, err := json.Marshal(candidates)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil
}
// Candidate issue titles are user-authored content scoped to the source
// repository, so classify the result like issue_read.
result := utils.NewToolResultText(string(r))
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent)
return result, nil, nil
})
st.FeatureRule = featureEnabledRule(FeatureFlagDuplicateDetection)
return st
}