Skip to content

Commit 3b57894

Browse files
alondahariCopilot
andcommitted
Add optional rationale parameter to update_issue_type tool
Add an optional `rationale` string parameter (max 280 chars) to the `update_issue_type` MCP tool. When provided, the type is sent as an object `{"name": "...", "rationale": "..."}` to the REST API, enabling agents to explain their classification decisions. When omitted, existing behavior is preserved (type sent as a plain string). This supports the agent rationale experiment for type mutations. The parameter is always visible in the schema — the API gracefully ignores the rationale when the server-side feature flag is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5259513 commit 3b57894

3 files changed

Lines changed: 120 additions & 34 deletions

File tree

pkg/github/__toolsnaps__/update_issue_type.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"description": "Repository owner (username or organization)",
2121
"type": "string"
2222
},
23+
"rationale": {
24+
"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).",
25+
"maxLength": 280,
26+
"type": "string"
27+
},
2328
"repo": {
2429
"description": "Repository name",
2530
"type": "string"

pkg/github/granular_tools_test.go

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -304,24 +304,57 @@ func TestGranularUpdateIssueMilestone(t *testing.T) {
304304
}
305305

306306
func TestGranularUpdateIssueType(t *testing.T) {
307-
client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
308-
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
309-
"type": "bug",
310-
}).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
311-
}))
312-
deps := BaseDeps{Client: client}
313-
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
314-
handler := serverTool.Handler(deps)
307+
tests := []struct {
308+
name string
309+
requestArgs map[string]any
310+
expectedReq map[string]any
311+
}{
312+
{
313+
name: "type only",
314+
requestArgs: map[string]any{
315+
"owner": "owner",
316+
"repo": "repo",
317+
"issue_number": float64(1),
318+
"issue_type": "bug",
319+
},
320+
expectedReq: map[string]any{
321+
"type": "bug",
322+
},
323+
},
324+
{
325+
name: "type with rationale",
326+
requestArgs: map[string]any{
327+
"owner": "owner",
328+
"repo": "repo",
329+
"issue_number": float64(1),
330+
"issue_type": "feature",
331+
"rationale": "This issue requests a new capability",
332+
},
333+
expectedReq: map[string]any{
334+
"type": map[string]any{
335+
"value": "feature",
336+
"rationale": "This issue requests a new capability",
337+
},
338+
},
339+
},
340+
}
315341

316-
request := createMCPRequest(map[string]any{
317-
"owner": "owner",
318-
"repo": "repo",
319-
"issue_number": float64(1),
320-
"issue_type": "bug",
321-
})
322-
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
323-
require.NoError(t, err)
324-
assert.False(t, result.IsError)
342+
for _, tc := range tests {
343+
t.Run(tc.name, func(t *testing.T) {
344+
client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
345+
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
346+
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
347+
}))
348+
deps := BaseDeps{Client: client}
349+
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
350+
handler := serverTool.Handler(deps)
351+
352+
request := createMCPRequest(tc.requestArgs)
353+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
354+
require.NoError(t, err)
355+
assert.False(t, result.IsError)
356+
})
357+
}
325358
}
326359

327360
func TestGranularUpdateIssueState(t *testing.T) {

pkg/github/issues_granular.go

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,24 @@ import (
1818
"github.com/shurcooL/githubv4"
1919
)
2020

21+
// issueEditBody is implemented by any type that can serve as the body
22+
// of a PATCH /repos/{owner}/{repo}/issues/{issue_number} request.
23+
type issueEditBody interface {
24+
issueEditBody()
25+
}
26+
27+
// standardIssueEdit wraps a *github.IssueRequest to satisfy issueEditBody.
28+
type standardIssueEdit struct{ *github.IssueRequest }
29+
30+
func (standardIssueEdit) issueEditBody() {}
31+
2132
// issueUpdateTool is a helper to create single-field issue update tools.
2233
func issueUpdateTool(
2334
t translations.TranslationHelperFunc,
2435
name, description, title string,
2536
extraProps map[string]*jsonschema.Schema,
2637
extraRequired []string,
27-
buildRequest func(args map[string]any) (*github.IssueRequest, error),
38+
buildRequest func(args map[string]any) (issueEditBody, error),
2839
) inventory.ServerTool {
2940
props := map[string]*jsonschema.Schema{
3041
"owner": {
@@ -77,7 +88,7 @@ func issueUpdateTool(
7788
return utils.NewToolResultError(err.Error()), nil, nil
7889
}
7990

80-
issueReq, err := buildRequest(args)
91+
body, err := buildRequest(args)
8192
if err != nil {
8293
return utils.NewToolResultError(err.Error()), nil, nil
8394
}
@@ -87,7 +98,14 @@ func issueUpdateTool(
8798
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
8899
}
89100

90-
issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueReq)
101+
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
102+
req, err := client.NewRequest("PATCH", apiURL, body)
103+
if err != nil {
104+
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
105+
}
106+
107+
issue := &github.Issue{}
108+
resp, err := client.Do(ctx, req, issue)
91109
if err != nil {
92110
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
93111
}
@@ -201,12 +219,12 @@ func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.Se
201219
"title": {Type: "string", Description: "The new title for the issue"},
202220
},
203221
[]string{"title"},
204-
func(args map[string]any) (*github.IssueRequest, error) {
222+
func(args map[string]any) (issueEditBody, error) {
205223
title, err := RequiredParam[string](args, "title")
206224
if err != nil {
207225
return nil, err
208226
}
209-
return &github.IssueRequest{Title: &title}, nil
227+
return standardIssueEdit{&github.IssueRequest{Title: &title}}, nil
210228
},
211229
)
212230
}
@@ -221,12 +239,12 @@ func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.Ser
221239
"body": {Type: "string", Description: "The new body content for the issue"},
222240
},
223241
[]string{"body"},
224-
func(args map[string]any) (*github.IssueRequest, error) {
242+
func(args map[string]any) (issueEditBody, error) {
225243
body, err := RequiredParam[string](args, "body")
226244
if err != nil {
227245
return nil, err
228246
}
229-
return &github.IssueRequest{Body: &body}, nil
247+
return standardIssueEdit{&github.IssueRequest{Body: &body}}, nil
230248
},
231249
)
232250
}
@@ -245,15 +263,15 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor
245263
},
246264
},
247265
[]string{"assignees"},
248-
func(args map[string]any) (*github.IssueRequest, error) {
266+
func(args map[string]any) (issueEditBody, error) {
249267
if _, ok := args["assignees"]; !ok {
250268
return nil, fmt.Errorf("missing required parameter: assignees")
251269
}
252270
assignees, err := OptionalStringArrayParam(args, "assignees")
253271
if err != nil {
254272
return nil, err
255273
}
256-
return &github.IssueRequest{Assignees: &assignees}, nil
274+
return standardIssueEdit{&github.IssueRequest{Assignees: &assignees}}, nil
257275
},
258276
)
259277
}
@@ -272,15 +290,15 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S
272290
},
273291
},
274292
[]string{"labels"},
275-
func(args map[string]any) (*github.IssueRequest, error) {
293+
func(args map[string]any) (issueEditBody, error) {
276294
if _, ok := args["labels"]; !ok {
277295
return nil, fmt.Errorf("missing required parameter: labels")
278296
}
279297
labels, err := OptionalStringArrayParam(args, "labels")
280298
if err != nil {
281299
return nil, err
282300
}
283-
return &github.IssueRequest{Labels: &labels}, nil
301+
return standardIssueEdit{&github.IssueRequest{Labels: &labels}}, nil
284302
},
285303
)
286304
}
@@ -299,16 +317,31 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor
299317
},
300318
},
301319
[]string{"milestone"},
302-
func(args map[string]any) (*github.IssueRequest, error) {
320+
func(args map[string]any) (issueEditBody, error) {
303321
milestone, err := RequiredInt(args, "milestone")
304322
if err != nil {
305323
return nil, err
306324
}
307-
return &github.IssueRequest{Milestone: &milestone}, nil
325+
return standardIssueEdit{&github.IssueRequest{Milestone: &milestone}}, nil
308326
},
309327
)
310328
}
311329

330+
// issueTypeWithRationale represents the object form of the issue type field,
331+
// allowing a rationale to be sent alongside the type value.
332+
type issueTypeWithRationale struct {
333+
Value string `json:"value"`
334+
Rationale string `json:"rationale"`
335+
}
336+
337+
// issueTypeUpdateRequest is a custom request body for updating an issue type
338+
// with an optional rationale, using the object form that the REST API accepts.
339+
type issueTypeUpdateRequest struct {
340+
Type issueTypeWithRationale `json:"type"`
341+
}
342+
343+
func (*issueTypeUpdateRequest) issueEditBody() {}
344+
312345
// GranularUpdateIssueType creates a tool to update an issue's type.
313346
func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool {
314347
return issueUpdateTool(t,
@@ -320,14 +353,29 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser
320353
Type: "string",
321354
Description: "The issue type to set",
322355
},
356+
"rationale": {
357+
Type: "string",
358+
Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " +
359+
"State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
360+
MaxLength: jsonschema.Ptr(280),
361+
},
323362
},
324363
[]string{"issue_type"},
325-
func(args map[string]any) (*github.IssueRequest, error) {
364+
func(args map[string]any) (issueEditBody, error) {
326365
issueType, err := RequiredParam[string](args, "issue_type")
327366
if err != nil {
328367
return nil, err
329368
}
330-
return &github.IssueRequest{Type: &issueType}, nil
369+
rationale, _ := OptionalParam[string](args, "rationale")
370+
if rationale != "" {
371+
return &issueTypeUpdateRequest{
372+
Type: issueTypeWithRationale{
373+
Value: issueType,
374+
Rationale: rationale,
375+
},
376+
}, nil
377+
}
378+
return standardIssueEdit{&github.IssueRequest{Type: &issueType}}, nil
331379
},
332380
)
333381
}
@@ -351,7 +399,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se
351399
},
352400
},
353401
[]string{"state"},
354-
func(args map[string]any) (*github.IssueRequest, error) {
402+
func(args map[string]any) (issueEditBody, error) {
355403
state, err := RequiredParam[string](args, "state")
356404
if err != nil {
357405
return nil, err
@@ -362,7 +410,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se
362410
if stateReason != "" {
363411
req.StateReason = &stateReason
364412
}
365-
return req, nil
413+
return standardIssueEdit{req}, nil
366414
},
367415
)
368416
}

0 commit comments

Comments
 (0)