Skip to content

Commit a02f4d3

Browse files
owenniblockCopilot
andcommitted
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>
1 parent 3b22154 commit a02f4d3

2 files changed

Lines changed: 34 additions & 73 deletions

File tree

pkg/github/issues.go

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,21 +2179,13 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
21792179
issueRequest.Type = github.Ptr(issueType)
21802180
}
21812181

2182-
// fallbackDeleteFieldIDs holds field IDs we need to clear via the dedicated
2183-
// REST DELETE endpoint after the PATCH lands. This is the omitempty-trap
2184-
// fallback: when the merged list is empty AND we have deletions to make,
2185-
// `go-github`'s `omitempty` tag on IssueRequest.IssueFieldValues strips the
2186-
// empty slice from the JSON body, the dotcom REST handler's top-level
2187-
// `if data.include?(ISSUE_FIELD_VALUES)` guard short-circuits, and nothing
2188-
// gets cleared. When the merged list is non-empty the PATCH's set semantics
2189-
// handle the deletes implicitly (current - new), so no DELETE follow-up is
2190-
// needed in that path.
2182+
// Field IDs to clear via DELETE after the PATCH. See the post-PATCH loop
2183+
// for why we can't just rely on REST set semantics.
21912184
var fallbackDeleteFieldIDs []int64
21922185

21932186
if len(issueFieldValues) > 0 || len(fieldIDsToDelete) > 0 {
2194-
// The REST update endpoint uses "set" semantics — it overwrites all existing
2195-
// field values with whatever is sent. Fetch the current values first, merge in
2196-
// the new values, then remove any explicitly deleted fields.
2187+
// REST PATCH uses set semantics, so fetch existing values, merge in
2188+
// the new ones, then drop anything explicitly deleted.
21972189
existing, err := fetchExistingIssueFieldValues(ctx, gqlClient, owner, repo, issueNumber)
21982190
if err != nil {
21992191
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil
@@ -2213,14 +2205,9 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
22132205
merged = kept
22142206
}
22152207
if len(merged) == 0 && len(fieldIDsToDelete) > 0 {
2216-
// Omitempty trap: skip the IssueFieldValues assignment so we don't
2217-
// rely on a value that's about to be stripped from the JSON anyway,
2218-
// and clear each field via the dedicated DELETE endpoint after the
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).
2208+
// Only queue DELETEs for fields actually present — the endpoint
2209+
// returns 404 otherwise, and "delete a field that isn't set" should
2210+
// stay a no-op (callers often invoke delete:true idempotently).
22242211
existingIDs := make(map[int64]bool, len(existing))
22252212
for _, e := range existing {
22262213
existingIDs[e.FieldID] = true
@@ -2253,17 +2240,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
22532240
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil
22542241
}
22552242

2256-
// Clear any fields whose deletion the PATCH couldn't carry. See the
2257-
// fallbackDeleteFieldIDs declaration above for the omitempty trap details.
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).
2243+
// Per-field DELETE fallback. The PATCH can't clear field values when the
2244+
// merged set is empty — go-github's `omitempty` strips the empty slice
2245+
// and the dotcom REST handler skips its issue_field_values block when the
2246+
// key is absent. Errors are aggregated (not short-circuited) so callers
2247+
// can see which fields succeeded and which need retry.
22672248
if len(fallbackDeleteFieldIDs) > 0 {
22682249
var failedIDs, succeededIDs []int64
22692250
var firstFailureErr error

pkg/github/issues_delete_test.go

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,10 @@ import (
1616
"github.com/stretchr/testify/require"
1717
)
1818

19-
// Test_IssueRequest_EmptyFieldValues_OmittedByJSON pins the omitempty behaviour
20-
// that motivates the DELETE-endpoint fallback in UpdateIssue. If go-github's
21-
// IssueRequest ever drops the `omitempty` tag from `issue_field_values`, this
22-
// test will fail — at which point the fallback could potentially be revisited.
23-
// Until then, an empty `[]*IssueRequestFieldValue{}` is serialised as nothing
24-
// at all, so the REST PATCH alone can never clear a field's last value via
25-
// the set-semantics path.
19+
// Test_IssueRequest_EmptyFieldValues_OmittedByJSON pins the omitempty
20+
// behaviour that makes the DELETE fallback necessary. If go-github ever drops
21+
// the tag, the REST PATCH alone could clear field values and this test would
22+
// fail to remind us.
2623
func Test_IssueRequest_EmptyFieldValues_OmittedByJSON(t *testing.T) {
2724
t.Parallel()
2825

@@ -39,19 +36,10 @@ func Test_IssueRequest_EmptyFieldValues_OmittedByJSON(t *testing.T) {
3936
"sanity check: other fields still serialise")
4037
}
4138

42-
// Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint: regression test for
43-
// the delete:true bug. When the kept set after merge + filter ends up empty
44-
// (e.g. deleting the only remaining field value), the PATCH alone cannot carry
45-
// the deletion intent because go-github strips the empty issue_field_values
46-
// slice via omitempty. UpdateIssue follows up with a per-field DELETE to the
47-
// dedicated `/repos/{owner}/{repo}/issues/{number}/issue-field-values/{id}`
48-
// endpoint.
49-
//
50-
// Asserts both halves:
51-
//
52-
// - the PATCH body does NOT carry an `issue_field_values` key (we don't want
53-
// to double-clear or rely on a value omitempty is about to strip)
54-
// - a DELETE for the field ID fires after the PATCH
39+
// Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint covers the bug fix:
40+
// when the kept set ends up empty, the PATCH alone can't clear the field
41+
// (omitempty strips the empty slice), so UpdateIssue follows up with a DELETE
42+
// to the dedicated endpoint.
5543
func Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint(t *testing.T) {
5644
t.Parallel()
5745

@@ -86,9 +74,8 @@ func Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint(t *testing.T) {
8674
},
8775
}))
8876

89-
// Existing field values for the merge step. Returning only the field about
90-
// to be deleted is the worst case: the kept list ends up empty and the
91-
// fallback DELETE is the only thing that can clear it.
77+
// Existing field values for the merge step. Returning the field we're
78+
// about to delete makes the kept list empty, triggering the fallback DELETE.
9279
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
9380
"repository": map[string]any{
9481
"issue": map[string]any{
@@ -151,10 +138,9 @@ func Test_UpdateIssue_DeleteLastFieldValueCallsDeleteEndpoint(t *testing.T) {
151138
"expected exactly one DELETE call to the dedicated endpoint for field id 101")
152139
}
153140

154-
// Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics verifies that when the kept
155-
// set after merge + filter is non-empty (deleting 1 of N existing fields), the
156-
// PATCH carries the kept fields and the dotcom REST handler's set semantics do
157-
// the deletion implicitly — no fallback DELETE call is needed.
141+
// Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics: when the kept set is
142+
// non-empty, set semantics handle the deletion implicitly via the PATCH — no
143+
// DELETE follow-up needed.
158144
func Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics(t *testing.T) {
159145
t.Parallel()
160146

@@ -248,12 +234,10 @@ func Test_UpdateIssue_DeleteOneOfManyUsesSetSemantics(t *testing.T) {
248234
"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")
249235
}
250236

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.
237+
// Test_UpdateIssue_DeleteAbsentFieldIsNoOp: deleting a field that isn't set
238+
// must not fire a DELETE (the endpoint would 404), preserving the pre-fix
239+
// silent-no-op behaviour so idempotent delete:true callers don't break on
240+
// retry.
257241
func Test_UpdateIssue_DeleteAbsentFieldIsNoOp(t *testing.T) {
258242
t.Parallel()
259243

@@ -343,11 +327,9 @@ func Test_UpdateIssue_DeleteAbsentFieldIsNoOp(t *testing.T) {
343327
"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")
344328
}
345329

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.
330+
// Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure: a failing DELETE
331+
// must not short-circuit subsequent ones, and the error must name which IDs
332+
// succeeded and which failed so callers can retry the right ones.
351333
func Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure(t *testing.T) {
352334
t.Parallel()
353335

@@ -373,10 +355,8 @@ func Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure(t *testing.T) {
373355
mu.Lock()
374356
deletePaths = append(deletePaths, r.URL.Path)
375357
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.
358+
// Field 202 fails; 101 and 303 succeed. All three should fire and
359+
// the error must name 202 as failed and 101/303 as cleared.
380360
if strings.HasSuffix(r.URL.Path, "/202") {
381361
w.WriteHeader(http.StatusInternalServerError)
382362
_, _ = w.Write([]byte(`{"message":"simulated failure"}`))

0 commit comments

Comments
 (0)