Skip to content

Commit 74c34cd

Browse files
iulia-bCopilotCopilotkerobbi
authored
Add rationale and confidence to closing an issue (#2802)
* Add rationale and confidence to closing an issue * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix review feedback for update_issue_state validation/docs --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu <kerobbi@github.com>
1 parent 49abf15 commit 74c34cd

4 files changed

Lines changed: 450 additions & 22 deletions

File tree

docs/feature-flags.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,12 @@ runtime behavior (such as output formatting) won't appear here.
178178

179179
- **update_issue_state** - Update Issue State
180180
- **Required OAuth Scopes**: `repo`
181+
- `confidence`: 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. (string, optional)
182+
- `duplicate_of`: 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. (number, optional)
183+
- `is_suggestion`: 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. (boolean, optional)
181184
- `issue_number`: The issue number to update (number, required)
182185
- `owner`: Repository owner (username or organization) (string, required)
186+
- `rationale`: 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). (string, optional)
183187
- `repo`: Repository name (string, required)
184188
- `state`: The new state for the issue (string, required)
185189
- `state_reason`: The reason for the state change (only for closed state) (string, optional)

pkg/github/__toolsnaps__/update_issue_state.snap

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,27 @@
66
"readOnlyHint": false,
77
"title": "Update Issue State"
88
},
9-
"description": "Update the state of an existing issue (open or closed), with an optional state reason.",
9+
"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.",
1010
"inputSchema": {
1111
"properties": {
12+
"confidence": {
13+
"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.",
14+
"enum": [
15+
"LOW",
16+
"MEDIUM",
17+
"HIGH"
18+
],
19+
"type": "string"
20+
},
21+
"duplicate_of": {
22+
"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.",
23+
"minimum": 1,
24+
"type": "number"
25+
},
26+
"is_suggestion": {
27+
"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.",
28+
"type": "boolean"
29+
},
1230
"issue_number": {
1331
"description": "The issue number to update",
1432
"minimum": 1,
@@ -18,6 +36,11 @@
1836
"description": "Repository owner (username or organization)",
1937
"type": "string"
2038
},
39+
"rationale": {
40+
"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).",
41+
"maxLength": 280,
42+
"type": "string"
43+
},
2144
"repo": {
2245
"description": "Repository name",
2346
"type": "string"

pkg/github/granular_tools_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,244 @@ func TestGranularUpdateIssueState(t *testing.T) {
11061106
}
11071107
}
11081108

1109+
func TestGranularUpdateIssueStateSuggest(t *testing.T) {
1110+
tests := []struct {
1111+
name string
1112+
requestArgs map[string]any
1113+
expectedReq map[string]any
1114+
}{
1115+
{
1116+
name: "suggest without rationale",
1117+
requestArgs: map[string]any{
1118+
"owner": "owner",
1119+
"repo": "repo",
1120+
"issue_number": float64(1),
1121+
"state": "closed",
1122+
"is_suggestion": true,
1123+
},
1124+
expectedReq: map[string]any{
1125+
"state": map[string]any{
1126+
"value": "closed",
1127+
"suggest": true,
1128+
},
1129+
},
1130+
},
1131+
{
1132+
name: "suggest with rationale and state_reason",
1133+
requestArgs: map[string]any{
1134+
"owner": "owner",
1135+
"repo": "repo",
1136+
"issue_number": float64(1),
1137+
"state": "closed",
1138+
"state_reason": "not_planned",
1139+
"rationale": " No activity in 6 months ",
1140+
"is_suggestion": true,
1141+
},
1142+
expectedReq: map[string]any{
1143+
"state": map[string]any{
1144+
"value": "closed",
1145+
"rationale": "No activity in 6 months",
1146+
"suggest": true,
1147+
},
1148+
"state_reason": "not_planned",
1149+
},
1150+
},
1151+
{
1152+
name: "rationale applied directly (no suggestion)",
1153+
requestArgs: map[string]any{
1154+
"owner": "owner",
1155+
"repo": "repo",
1156+
"issue_number": float64(1),
1157+
"state": "closed",
1158+
"rationale": "The reported crash is fixed in v2.1",
1159+
"confidence": "HIGH",
1160+
},
1161+
expectedReq: map[string]any{
1162+
"state": map[string]any{
1163+
"value": "closed",
1164+
"rationale": "The reported crash is fixed in v2.1",
1165+
"confidence": "HIGH",
1166+
},
1167+
},
1168+
},
1169+
}
1170+
1171+
for _, tc := range tests {
1172+
t.Run(tc.name, func(t *testing.T) {
1173+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1174+
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
1175+
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
1176+
}))
1177+
deps := BaseDeps{Client: client}
1178+
serverTool := GranularUpdateIssueState(translations.NullTranslationHelper)
1179+
handler := serverTool.Handler(deps)
1180+
1181+
request := createMCPRequest(tc.requestArgs)
1182+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1183+
require.NoError(t, err)
1184+
assert.False(t, result.IsError)
1185+
})
1186+
}
1187+
}
1188+
1189+
func TestGranularUpdateIssueStateDuplicate(t *testing.T) {
1190+
const duplicateIssueID = int64(99999)
1191+
1192+
tests := []struct {
1193+
name string
1194+
requestArgs map[string]any
1195+
expectedReq map[string]any
1196+
}{
1197+
{
1198+
name: "suggestion duplicate close",
1199+
requestArgs: map[string]any{
1200+
"owner": "owner",
1201+
"repo": "repo",
1202+
"issue_number": float64(1),
1203+
"state": "closed",
1204+
"state_reason": "duplicate",
1205+
"is_suggestion": true,
1206+
"duplicate_of": float64(42),
1207+
},
1208+
expectedReq: map[string]any{
1209+
"state": map[string]any{
1210+
"value": "closed",
1211+
"suggest": true,
1212+
},
1213+
"state_reason": "duplicate",
1214+
"duplicate_issue_id": float64(duplicateIssueID),
1215+
},
1216+
},
1217+
{
1218+
name: "direct duplicate close",
1219+
requestArgs: map[string]any{
1220+
"owner": "owner",
1221+
"repo": "repo",
1222+
"issue_number": float64(1),
1223+
"state": "closed",
1224+
"state_reason": "duplicate",
1225+
"duplicate_of": float64(42),
1226+
},
1227+
expectedReq: map[string]any{
1228+
"state": map[string]any{"value": "closed"},
1229+
"state_reason": "duplicate",
1230+
"duplicate_issue_id": float64(duplicateIssueID),
1231+
},
1232+
},
1233+
}
1234+
1235+
for _, tc := range tests {
1236+
t.Run(tc.name, func(t *testing.T) {
1237+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1238+
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{
1239+
ID: gogithub.Ptr(duplicateIssueID),
1240+
Number: gogithub.Ptr(42),
1241+
}),
1242+
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
1243+
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
1244+
}))
1245+
deps := BaseDeps{Client: client}
1246+
serverTool := GranularUpdateIssueState(translations.NullTranslationHelper)
1247+
handler := serverTool.Handler(deps)
1248+
1249+
request := createMCPRequest(tc.requestArgs)
1250+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1251+
require.NoError(t, err)
1252+
assert.False(t, result.IsError)
1253+
})
1254+
}
1255+
}
1256+
1257+
func TestGranularUpdateIssueStateInvalidRationale(t *testing.T) {
1258+
tests := []struct {
1259+
name string
1260+
requestArgs map[string]any
1261+
expectedErrText string
1262+
}{
1263+
{
1264+
name: "rationale too long",
1265+
requestArgs: map[string]any{
1266+
"owner": "owner",
1267+
"repo": "repo",
1268+
"issue_number": float64(1),
1269+
"state": "closed",
1270+
"rationale": strings.Repeat("a", 281),
1271+
},
1272+
expectedErrText: "parameter rationale must be 280 characters or less",
1273+
},
1274+
{
1275+
name: "duplicate_of without state_reason duplicate",
1276+
requestArgs: map[string]any{
1277+
"owner": "owner",
1278+
"repo": "repo",
1279+
"issue_number": float64(1),
1280+
"state": "closed",
1281+
"state_reason": "not_planned",
1282+
"is_suggestion": true,
1283+
"duplicate_of": float64(42),
1284+
},
1285+
expectedErrText: "duplicate_of can only be used when state_reason is 'duplicate'",
1286+
},
1287+
{
1288+
name: "suggestion duplicate without duplicate_of",
1289+
requestArgs: map[string]any{
1290+
"owner": "owner",
1291+
"repo": "repo",
1292+
"issue_number": float64(1),
1293+
"state": "closed",
1294+
"state_reason": "duplicate",
1295+
"is_suggestion": true,
1296+
},
1297+
expectedErrText: "duplicate_of is required when suggesting a close as duplicate",
1298+
},
1299+
{
1300+
name: "state_reason with open state",
1301+
requestArgs: map[string]any{
1302+
"owner": "owner",
1303+
"repo": "repo",
1304+
"issue_number": float64(1),
1305+
"state": "open",
1306+
"state_reason": "completed",
1307+
},
1308+
expectedErrText: "state_reason can only be used when state is 'closed'",
1309+
},
1310+
}
1311+
1312+
for _, tc := range tests {
1313+
t.Run(tc.name, func(t *testing.T) {
1314+
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
1315+
serverTool := GranularUpdateIssueState(translations.NullTranslationHelper)
1316+
handler := serverTool.Handler(deps)
1317+
1318+
request := createMCPRequest(tc.requestArgs)
1319+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1320+
require.NoError(t, err)
1321+
1322+
errorContent := getErrorResult(t, result)
1323+
assert.Contains(t, errorContent.Text, tc.expectedErrText)
1324+
})
1325+
}
1326+
}
1327+
1328+
func TestGranularUpdateIssueStateInvalidConfidence(t *testing.T) {
1329+
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
1330+
serverTool := GranularUpdateIssueState(translations.NullTranslationHelper)
1331+
handler := serverTool.Handler(deps)
1332+
1333+
request := createMCPRequest(map[string]any{
1334+
"owner": "owner",
1335+
"repo": "repo",
1336+
"issue_number": float64(1),
1337+
"state": "closed",
1338+
"confidence": "VERY_HIGH",
1339+
})
1340+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1341+
require.NoError(t, err)
1342+
1343+
errorContent := getErrorResult(t, result)
1344+
assert.Contains(t, errorContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH")
1345+
}
1346+
11091347
// --- Pull request granular tool handler tests ---
11101348

11111349
func TestGranularUpdatePullRequestTitle(t *testing.T) {

0 commit comments

Comments
 (0)