Skip to content

Commit 10d7777

Browse files
fix(issues): preserve zero pagination for duplicate search
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9896c0e commit 10d7777

6 files changed

Lines changed: 508 additions & 183 deletions

File tree

‎docs/feature-flags.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,8 @@ as output formatting) won't appear here.
402402
- `confidence_threshold`: 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. (number, optional)
403403
- `issue_number`: The number of the existing issue to find duplicates for (number, required)
404404
- `owner`: The owner of the repository (string, required)
405-
- `page`: Page number for pagination (min 1) (number, optional)
406-
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
405+
- `page`: Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1. (number, optional)
406+
- `perPage`: Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30. (number, optional)
407407
- `repo`: The name of the repository (string, required)
408408

409409
### `thread_resolution_reason`

‎pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@
2020
"type": "string"
2121
},
2222
"page": {
23-
"description": "Page number for pagination (min 1)",
24-
"minimum": 0,
23+
"description": "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1.",
24+
"minimum": 1,
2525
"type": "number"
2626
},
2727
"perPage": {
28-
"description": "Results per page for pagination (min 1, max 100)",
28+
"description": "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30.",
2929
"maximum": 100,
30-
"minimum": 0,
30+
"minimum": 1,
3131
"type": "number"
3232
},
3333
"repo": {
@@ -42,5 +42,66 @@
4242
],
4343
"type": "object"
4444
},
45-
"name": "find_duplicate"
45+
"name": "find_duplicate",
46+
"outputSchema": {
47+
"items": {
48+
"additionalProperties": false,
49+
"properties": {
50+
"confidence": {
51+
"description": "The API-provided confidence category.",
52+
"type": "string"
53+
},
54+
"issue": {
55+
"additionalProperties": false,
56+
"properties": {
57+
"html_url": {
58+
"description": "The web URL of the candidate issue.",
59+
"type": "string"
60+
},
61+
"number": {
62+
"description": "The candidate issue number.",
63+
"type": "integer"
64+
},
65+
"state": {
66+
"description": "The candidate issue state.",
67+
"type": "string"
68+
},
69+
"title": {
70+
"description": "The sanitized candidate issue title.",
71+
"type": "string"
72+
}
73+
},
74+
"required": [
75+
"number",
76+
"title",
77+
"state",
78+
"html_url"
79+
],
80+
"type": "object"
81+
},
82+
"likely_duplicate": {
83+
"description": "Whether the API considers this candidate a likely duplicate.",
84+
"type": "boolean"
85+
},
86+
"score": {
87+
"description": "Similarity score on the API-defined scale; null when the API does not return a score.",
88+
"type": [
89+
"null",
90+
"number"
91+
]
92+
}
93+
},
94+
"required": [
95+
"issue",
96+
"score",
97+
"confidence",
98+
"likely_duplicate"
99+
],
100+
"type": "object"
101+
},
102+
"type": [
103+
"null",
104+
"array"
105+
]
106+
}
46107
}

‎pkg/github/__toolsnaps__/find_duplicate_typed.snap‎

Lines changed: 0 additions & 117 deletions
This file was deleted.

‎pkg/github/find_duplicate.go‎

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,6 @@ type rankedSimilarIssue struct {
3333
LikelyDuplicate bool `json:"likely_duplicate"`
3434
}
3535

36-
// DuplicateCandidate is the trimmed output for a ranked duplicate candidate,
37-
// carrying only what an agent needs to explain and act on it.
38-
type DuplicateCandidate struct {
39-
Issue MinimalIssueRef `json:"issue"`
40-
Score *float64 `json:"score"`
41-
Confidence string `json:"confidence"`
42-
LikelyDuplicate bool `json:"likely_duplicate"`
43-
}
44-
4536
// FindDuplicate creates a read-only tool that returns ranked duplicate
4637
// candidates for an existing issue. It is a separate, feature-flagged tool so
4738
// duplicate detection is only advertised when explicitly opted in, keeping the
@@ -71,14 +62,15 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
7162
Required: []string{"owner", "repo", "issue_number"},
7263
}
7364
WithPagination(schema)
74-
// This endpoint owns its defaults. Unlike issue_dependency_read, explicit
75-
// zero pagination values must be forwarded, not replaced by SDK defaults.
76-
for _, field := range []string{"page", "perPage"} {
77-
schema.Properties[field].Default = nil
78-
schema.Properties[field].Minimum = new(0.0)
79-
}
65+
// Unlike most tools, find_duplicate does not substitute its own default
66+
// for an explicit 0: it forwards whatever page/perPage value is supplied
67+
// (including 0) to the API unchanged, letting the API apply its own
68+
// default. Document that exception here instead of relying solely on the
69+
// generic WithPagination wording.
70+
schema.Properties["page"].Description = "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1."
71+
schema.Properties["perPage"].Description = "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30."
8072

81-
st := NewTool[FindDuplicateInput, []DuplicateCandidate](
73+
st := NewToolWithSchemaOptions[FindDuplicateInput, []DuplicateCandidate](
8274
ToolsetMetadataIssues,
8375
mcp.Tool{
8476
Name: "find_duplicate",
@@ -87,9 +79,11 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
8779
Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"),
8880
ReadOnlyHint: true,
8981
},
90-
InputSchema: schema,
82+
InputSchema: schema,
83+
OutputSchema: findDuplicateOutputSchema(),
9184
},
9285
scopes.PublicRead(scopes.Repo),
86+
inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema)},
9387
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input FindDuplicateInput) (*mcp.CallToolResult, []DuplicateCandidate, error) {
9488
owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber
9589
if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil {
@@ -132,30 +126,41 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool {
132126
defer func() { _ = resp.Body.Close() }()
133127

134128
candidates := make([]DuplicateCandidate, 0, len(results))
129+
legacyCandidates := make([]LegacyDuplicateCandidate, 0, len(results))
135130
for _, res := range results {
136131
// A bare issue (no ranking metadata) means ranked duplicate
137132
// detection is not enabled for this caller; fail clearly rather
138133
// than returning incomplete candidates.
139134
if res.Confidence == "" || res.Issue == nil {
140135
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
141136
}
137+
ref := newMinimalIssueRef(
138+
res.Issue.Number,
139+
res.Issue.Title,
140+
res.Issue.State,
141+
res.Issue.HTMLURL,
142+
"",
143+
)
142144
candidates = append(candidates, DuplicateCandidate{
143145
// Candidates are always scoped to the requested repository, so the
144146
// ref's repository field is left empty as it was before.
145-
Issue: newMinimalIssueRef(
146-
res.Issue.Number,
147-
res.Issue.Title,
148-
res.Issue.State,
149-
res.Issue.HTMLURL,
150-
"",
151-
),
147+
Issue: MinimalDuplicateIssue{
148+
Number: ref.Number, Title: ref.Title,
149+
State: DuplicateIssueState(ref.State), HTMLURL: ref.URL,
150+
},
151+
Score: res.Score,
152+
Confidence: DuplicateConfidence(res.Confidence),
153+
LikelyDuplicate: res.LikelyDuplicate,
154+
})
155+
legacyCandidates = append(legacyCandidates, LegacyDuplicateCandidate{
156+
Issue: ref,
152157
Score: res.Score,
153-
Confidence: res.Confidence,
158+
Confidence: DuplicateConfidence(res.Confidence),
154159
LikelyDuplicate: res.LikelyDuplicate,
155160
})
156161
}
157162

158-
r, err := json.Marshal(candidates)
163+
r, err := json.Marshal(legacyCandidates)
159164
if err != nil {
160165
return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil
161166
}

0 commit comments

Comments
 (0)