Skip to content

Commit 29634da

Browse files
owenniblockCopilot
andauthored
Fix delete:true on issue fields by calling deleteIssueFieldValue mutation (#2755)
* Fall back to REST DELETE when the PATCH can't carry an issue field clear The dotcom REST update endpoint uses set semantics for issue_field_values: sending {"issue_field_values": [...]} overwrites the whole list, and anything not in the new list is treated as a deletion. UpdateIssue already exploits this via merge-and-filter — delete:true on a field removes it from the kept list and the server clears it as a side effect. That breaks for one specific case: when the kept list ends up empty (e.g. you're deleting the only remaining field value, or every field in one call), go-github's omitempty tag on `IssueRequest.IssueFieldValues` strips the empty slice from the JSON body. The dotcom REST handler's top-level `if data.include?(ISSUE_FIELD_VALUES)` guard then short-circuits — the key isn't in the payload, so the whole block is skipped and the field keeps its old value. The MCP tool returns success regardless, so a coding agent would happily report the field as cleared. Detect this case in UpdateIssue and fall back to the dedicated REST DELETE endpoint per field: DELETE /repos/{owner}/{repo}/issues/{number}/ issue-field-values/{field_id}. The endpoint is idempotent, takes the integer field ID (no GraphQL node ID needed), and is the same one the public OpenAPI documents. Empirical confirmation on #2756: - Set Priority=P1 via REST - Before the fix: MCP issue_write delete:true returns success, PATCH body on the wire is literally {}, Priority remains P1 (silent no-op). - After the fix: MCP issue_write delete:true returns success, PATCH body is still {}, but the follow-up DELETE clears the field. Priority is cleared as expected. Three new tests in issues_delete_test.go cover: - The omitempty contract (so we know if go-github ever drops the tag) - The 1-of-1 fallback path (PATCH body has no issue_field_values, DELETE fires) - The N-1 set-semantics path (kept list is non-empty, PATCH alone handles it, no DELETE call) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: handle delete-absent-field and DELETE partial failures Two issues surfaced in code review of the original delete fix: 1. Asking to delete a field that isn't set on the issue used to be a silent no-op (PATCH body is {} after omitempty stripping, server skips the issue_field_values block, tool returns success). The fix turned it into a hard error because the fallback DELETE loop fires unconditionally for every field ID in fieldIDsToDelete, and the dotcom DELETE endpoint returns 404 when the field has no value to delete. This breaks idempotent 'ensure field X is cleared' callers that may re-run the same delete on a field that's already been cleared. Fix: before queueing the fallback DELETEs, filter fallbackDeleteFieldIDs down to IDs that actually appeared in the existing field values. This preserves the pre-fix silent-no-op behaviour and avoids a guaranteed 404. 2. The DELETE loop returned on the first error. If a caller asks to clear three fields and the second fails (transient 5xx, rate limit, etc.), the first is gone, the third is never attempted, and the user gets a generic error with no indication of which field failed or which had already succeeded. This is unrecoverable from the caller side. Fix: continue on per-field errors, accumulate the failed and succeeded IDs, and return a single aggregated error naming both sets so callers can retry the right fields. Tests: - Test_UpdateIssue_DeleteAbsentFieldIsNoOp: existing field values is empty, fieldIDsToDelete=[101]. Asserts no DELETE call fires (the mock returns 404 if it does, which would fail the test) and the tool returns success. - Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure: three fields exist, all three are deleted. Mock returns 500 for the middle DELETE, 204 for the others. Asserts all three DELETEs fired (the middle failure didn't short-circuit the third) and the error result names failed=[202] and cleared=[101 303]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Slim down code comments in delete-fix region Trim the doc comments on UpdateIssue's omitempty-trap region + test file to the bare minimum needed to understand the non-obvious behaviour (why the DELETE fallback exists, why we filter to existing IDs, why errors are aggregated). Drop restated context and step-by-step explanations. No behaviour change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 067756a commit 29634da

3 files changed

Lines changed: 499 additions & 4 deletions

File tree

pkg/github/helper_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const (
6666
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
6767
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue"
6868
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
69+
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values/{issue_field_id}"
6970

7071
// Pull request endpoints
7172
GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls"

pkg/github/issues.go

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,10 +2148,13 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
21482148
issueRequest.Type = github.Ptr(issueType)
21492149
}
21502150

2151+
// Field IDs to clear via DELETE after the PATCH. See the post-PATCH loop
2152+
// for why we can't just rely on REST set semantics.
2153+
var fallbackDeleteFieldIDs []int64
2154+
21512155
if len(issueFieldValues) > 0 || len(fieldIDsToDelete) > 0 {
2152-
// The REST update endpoint uses "set" semantics — it overwrites all existing
2153-
// field values with whatever is sent. Fetch the current values first, merge in
2154-
// the new values, then remove any explicitly deleted fields.
2156+
// REST PATCH uses set semantics, so fetch existing values, merge in
2157+
// the new ones, then drop anything explicitly deleted.
21552158
existing, err := fetchExistingIssueFieldValues(ctx, gqlClient, owner, repo, issueNumber)
21562159
if err != nil {
21572160
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil
@@ -2170,7 +2173,22 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
21702173
}
21712174
merged = kept
21722175
}
2173-
issueRequest.IssueFieldValues = merged
2176+
if len(merged) == 0 && len(fieldIDsToDelete) > 0 {
2177+
// Only queue DELETEs for fields actually present — the endpoint
2178+
// returns 404 otherwise, and "delete a field that isn't set" should
2179+
// stay a no-op (callers often invoke delete:true idempotently).
2180+
existingIDs := make(map[int64]bool, len(existing))
2181+
for _, e := range existing {
2182+
existingIDs[e.FieldID] = true
2183+
}
2184+
for _, id := range fieldIDsToDelete {
2185+
if existingIDs[id] {
2186+
fallbackDeleteFieldIDs = append(fallbackDeleteFieldIDs, id)
2187+
}
2188+
}
2189+
} else {
2190+
issueRequest.IssueFieldValues = merged
2191+
}
21742192
}
21752193

21762194
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
@@ -2191,6 +2209,43 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
21912209
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil
21922210
}
21932211

2212+
// Per-field DELETE fallback. The PATCH can't clear field values when the
2213+
// merged set is empty — go-github's `omitempty` strips the empty slice
2214+
// and the dotcom REST handler skips its issue_field_values block when the
2215+
// key is absent. Errors are aggregated (not short-circuited) so callers
2216+
// can see which fields succeeded and which need retry.
2217+
if len(fallbackDeleteFieldIDs) > 0 {
2218+
var failedIDs, succeededIDs []int64
2219+
var firstFailureErr error
2220+
var firstFailureResp *github.Response
2221+
for _, fieldID := range fallbackDeleteFieldIDs {
2222+
path := fmt.Sprintf("repos/%s/%s/issues/%d/issue-field-values/%d", owner, repo, issueNumber, fieldID)
2223+
req, err := client.NewRequest(ctx, http.MethodDelete, path, nil)
2224+
if err != nil {
2225+
failedIDs = append(failedIDs, fieldID)
2226+
if firstFailureErr == nil {
2227+
firstFailureErr = err
2228+
}
2229+
continue
2230+
}
2231+
delResp, err := client.Do(req, nil)
2232+
if err != nil {
2233+
failedIDs = append(failedIDs, fieldID)
2234+
if firstFailureErr == nil {
2235+
firstFailureErr = err
2236+
firstFailureResp = delResp
2237+
}
2238+
continue
2239+
}
2240+
succeededIDs = append(succeededIDs, fieldID)
2241+
_ = delResp.Body.Close()
2242+
}
2243+
if len(failedIDs) > 0 {
2244+
msg := fmt.Sprintf("failed to clear issue field values: failed=%v, cleared=%v", failedIDs, succeededIDs)
2245+
return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil
2246+
}
2247+
}
2248+
21942249
// Use GraphQL API for state updates
21952250
if state != "" {
21962251
// Mandate specifying duplicateOf when trying to close as duplicate

0 commit comments

Comments
 (0)