Skip to content

Commit b399b6c

Browse files
owenniblockCopilot
andcommitted
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>
1 parent 6a07793 commit b399b6c

2 files changed

Lines changed: 262 additions & 16 deletions

File tree

pkg/github/issues.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2216,8 +2216,20 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
22162216
// Omitempty trap: skip the IssueFieldValues assignment so we don't
22172217
// rely on a value that's about to be stripped from the JSON anyway,
22182218
// and clear each field via the dedicated DELETE endpoint after the
2219-
// PATCH lands.
2220-
fallbackDeleteFieldIDs = fieldIDsToDelete
2219+
// PATCH lands. Only queue a DELETE for fields actually present on
2220+
// the issue — the dotcom DELETE endpoint returns 404 for absent
2221+
// values, and we want to preserve the pre-fix behaviour of treating
2222+
// "delete a field that isn't set" as a silent no-op (which is what
2223+
// happens when the user idempotently re-runs the same call).
2224+
existingIDs := make(map[int64]bool, len(existing))
2225+
for _, e := range existing {
2226+
existingIDs[e.FieldID] = true
2227+
}
2228+
for _, id := range fieldIDsToDelete {
2229+
if existingIDs[id] {
2230+
fallbackDeleteFieldIDs = append(fallbackDeleteFieldIDs, id)
2231+
}
2232+
}
22212233
} else {
22222234
issueRequest.IssueFieldValues = merged
22232235
}
@@ -2243,22 +2255,45 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
22432255

22442256
// Clear any fields whose deletion the PATCH couldn't carry. See the
22452257
// fallbackDeleteFieldIDs declaration above for the omitempty trap details.
2246-
// We hit the dedicated DELETE endpoint per field — it's idempotent, takes
2247-
// the integer field ID, and the URL is the standard `/repos/{owner}/{repo}`
2248-
// pattern (no need for the numeric repository ID despite what the internal
2249-
// route in app/api/issue_field_values.rb suggests; the public API rewrites
2250-
// to it).
2251-
for _, fieldID := range fallbackDeleteFieldIDs {
2252-
path := fmt.Sprintf("repos/%s/%s/issues/%d/issue-field-values/%d", owner, repo, issueNumber, fieldID)
2253-
req, err := client.NewRequest(ctx, http.MethodDelete, path, nil)
2254-
if err != nil {
2255-
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to build DELETE request for issue field value", nil, err), nil
2258+
// We hit the dedicated DELETE endpoint per field — it takes the integer
2259+
// field ID, and the URL is the standard `/repos/{owner}/{repo}` pattern.
2260+
//
2261+
// Per-field errors don't short-circuit: each DELETE is attempted, and any
2262+
// failures are aggregated into a single error response that names the
2263+
// successful and failed field IDs. This avoids leaving the caller blind
2264+
// to which deletions landed when one of several fails (e.g. transient
2265+
// 5xx on field 2 of 3 — without aggregation, field 1 is already gone and
2266+
// field 3 was never attempted, but the caller only sees a generic error).
2267+
if len(fallbackDeleteFieldIDs) > 0 {
2268+
var failedIDs, succeededIDs []int64
2269+
var firstFailureErr error
2270+
var firstFailureResp *github.Response
2271+
for _, fieldID := range fallbackDeleteFieldIDs {
2272+
path := fmt.Sprintf("repos/%s/%s/issues/%d/issue-field-values/%d", owner, repo, issueNumber, fieldID)
2273+
req, err := client.NewRequest(ctx, http.MethodDelete, path, nil)
2274+
if err != nil {
2275+
failedIDs = append(failedIDs, fieldID)
2276+
if firstFailureErr == nil {
2277+
firstFailureErr = err
2278+
}
2279+
continue
2280+
}
2281+
delResp, err := client.Do(req, nil)
2282+
if err != nil {
2283+
failedIDs = append(failedIDs, fieldID)
2284+
if firstFailureErr == nil {
2285+
firstFailureErr = err
2286+
firstFailureResp = delResp
2287+
}
2288+
continue
2289+
}
2290+
succeededIDs = append(succeededIDs, fieldID)
2291+
_ = delResp.Body.Close()
22562292
}
2257-
delResp, err := client.Do(req, nil)
2258-
if err != nil {
2259-
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to clear issue field value", delResp, err), nil
2293+
if len(failedIDs) > 0 {
2294+
msg := fmt.Sprintf("failed to clear issue field values: failed=%v, cleared=%v", failedIDs, succeededIDs)
2295+
return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil
22602296
}
2261-
_ = delResp.Body.Close()
22622297
}
22632298

22642299
// Use GraphQL API for state updates

pkg/github/issues_delete_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"io"
77
"net/http"
8+
"strings"
89
"sync"
910
"testing"
1011

@@ -246,3 +247,213 @@ func Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics(t *testing.T) {
246247
require.Empty(t, deletePaths,
247248
"no DELETE call should fire when the kept set is non-empty — the PATCH's set semantics clear the deleted field on the server side")
248249
}
250+
251+
// Test_UpdateIssue_DeleteAbsentFieldIsNoOp verifies that asking to delete a
252+
// field that isn't currently set on the issue does not fire a DELETE request
253+
// to the dedicated endpoint (which would 404). This preserves the pre-fix
254+
// behaviour of treating "delete a field that isn't set" as a silent no-op —
255+
// important because callers often use delete:true idempotently ("ensure
256+
// field X is cleared"), and the second invocation should succeed not error.
257+
func Test_UpdateIssue_DeleteAbsentFieldIsNoOp(t *testing.T) {
258+
t.Parallel()
259+
260+
mockIssue := &gogithub.Issue{
261+
Number: gogithub.Ptr(42),
262+
Title: gogithub.Ptr("Test issue"),
263+
State: gogithub.Ptr("open"),
264+
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
265+
}
266+
267+
var (
268+
mu sync.Mutex
269+
capturedPatchBody []byte
270+
deletePaths []string
271+
)
272+
273+
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
274+
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
275+
body, _ := io.ReadAll(r.Body)
276+
mu.Lock()
277+
capturedPatchBody = body
278+
mu.Unlock()
279+
w.Header().Set("Content-Type", "application/json")
280+
w.WriteHeader(http.StatusOK)
281+
_ = json.NewEncoder(w).Encode(mockIssue)
282+
},
283+
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
284+
mu.Lock()
285+
deletePaths = append(deletePaths, r.URL.Path)
286+
mu.Unlock()
287+
// Fail loudly: if we get here, the fix is wrong.
288+
w.WriteHeader(http.StatusNotFound)
289+
},
290+
}))
291+
292+
// Issue has no field values at all.
293+
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
294+
"repository": map[string]any{
295+
"issue": map[string]any{
296+
"issueFieldValues": map[string]any{
297+
"nodes": []any{},
298+
},
299+
},
300+
},
301+
})
302+
303+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
304+
githubv4mock.NewQueryMatcher(
305+
struct {
306+
Repository struct {
307+
Issue struct {
308+
IssueFieldValues struct {
309+
Nodes []IssueFieldValueFragment
310+
} `graphql:"issueFieldValues(first: 25)"`
311+
} `graphql:"issue(number: $number)"`
312+
} `graphql:"repository(owner: $owner, name: $repo)"`
313+
}{},
314+
map[string]any{
315+
"owner": githubv4.String("owner"),
316+
"repo": githubv4.String("repo"),
317+
"number": githubv4.Int(42),
318+
},
319+
existingFieldsResponse,
320+
),
321+
))
322+
323+
result, err := UpdateIssue(
324+
context.Background(),
325+
restClient,
326+
gqlClient,
327+
"owner", "repo", 42,
328+
"", "", nil, nil, 0, "",
329+
nil,
330+
[]int64{101}, // ask to delete a field that isn't set
331+
"", "", 0,
332+
)
333+
require.NoError(t, err)
334+
if result.IsError {
335+
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
336+
}
337+
338+
mu.Lock()
339+
defer mu.Unlock()
340+
require.NotContains(t, string(capturedPatchBody), "issue_field_values",
341+
"PATCH body must not carry issue_field_values when nothing changed")
342+
require.Empty(t, deletePaths,
343+
"no DELETE call should fire for a field that isn't present on the issue — preserves the pre-fix silent-no-op behaviour and avoids a guaranteed 404")
344+
}
345+
346+
// Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure verifies that when
347+
// one DELETE in the fallback loop fails, the remaining DELETEs still fire and
348+
// the aggregated error names the failed and succeeded field IDs. The pre-fix
349+
// loop short-circuited on the first failure, which left the caller blind to
350+
// which deletions had landed.
351+
func Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure(t *testing.T) {
352+
t.Parallel()
353+
354+
mockIssue := &gogithub.Issue{
355+
Number: gogithub.Ptr(42),
356+
Title: gogithub.Ptr("Test issue"),
357+
State: gogithub.Ptr("open"),
358+
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
359+
}
360+
361+
var (
362+
mu sync.Mutex
363+
deletePaths []string
364+
)
365+
366+
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
367+
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
368+
w.Header().Set("Content-Type", "application/json")
369+
w.WriteHeader(http.StatusOK)
370+
_ = json.NewEncoder(w).Encode(mockIssue)
371+
},
372+
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
373+
mu.Lock()
374+
deletePaths = append(deletePaths, r.URL.Path)
375+
mu.Unlock()
376+
// Field 202 returns 500; fields 101 and 303 succeed. We expect
377+
// all three calls to fire even though the middle one errors, and
378+
// the final tool result must mention 202 as failed and 101/303
379+
// as cleared.
380+
if strings.HasSuffix(r.URL.Path, "/202") {
381+
w.WriteHeader(http.StatusInternalServerError)
382+
_, _ = w.Write([]byte(`{"message":"simulated failure"}`))
383+
return
384+
}
385+
w.WriteHeader(http.StatusNoContent)
386+
},
387+
}))
388+
389+
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
390+
"repository": map[string]any{
391+
"issue": map[string]any{
392+
"issueFieldValues": map[string]any{
393+
"nodes": []any{
394+
map[string]any{
395+
"__typename": "IssueFieldSingleSelectValue",
396+
"field": map[string]any{"fullDatabaseId": "101", "name": "Priority"},
397+
"value": "P1",
398+
},
399+
map[string]any{
400+
"__typename": "IssueFieldSingleSelectValue",
401+
"field": map[string]any{"fullDatabaseId": "202", "name": "Visibility"},
402+
"value": "High",
403+
},
404+
map[string]any{
405+
"__typename": "IssueFieldSingleSelectValue",
406+
"field": map[string]any{"fullDatabaseId": "303", "name": "Impact"},
407+
"value": "Critical",
408+
},
409+
},
410+
},
411+
},
412+
},
413+
})
414+
415+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
416+
githubv4mock.NewQueryMatcher(
417+
struct {
418+
Repository struct {
419+
Issue struct {
420+
IssueFieldValues struct {
421+
Nodes []IssueFieldValueFragment
422+
} `graphql:"issueFieldValues(first: 25)"`
423+
} `graphql:"issue(number: $number)"`
424+
} `graphql:"repository(owner: $owner, name: $repo)"`
425+
}{},
426+
map[string]any{
427+
"owner": githubv4.String("owner"),
428+
"repo": githubv4.String("repo"),
429+
"number": githubv4.Int(42),
430+
},
431+
existingFieldsResponse,
432+
),
433+
))
434+
435+
result, err := UpdateIssue(
436+
context.Background(),
437+
restClient,
438+
gqlClient,
439+
"owner", "repo", 42,
440+
"", "", nil, nil, 0, "",
441+
nil,
442+
[]int64{101, 202, 303},
443+
"", "", 0,
444+
)
445+
require.NoError(t, err)
446+
require.True(t, result.IsError, "expected an error result because field 202 failed")
447+
448+
mu.Lock()
449+
defer mu.Unlock()
450+
// All three DELETEs must have fired — the middle failure must not short-circuit the third.
451+
require.Len(t, deletePaths, 3,
452+
"all three DELETE calls should fire even though one fails; got paths: %v", deletePaths)
453+
454+
resultText := getTextResult(t, result).Text
455+
require.Contains(t, resultText, "failed=[202]",
456+
"error must name the failed field ID so the caller can retry it; got: %s", resultText)
457+
require.Contains(t, resultText, "cleared=[101 303]",
458+
"error must name the cleared field IDs so the caller knows what's already done; got: %s", resultText)
459+
}

0 commit comments

Comments
 (0)