Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const (
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue"
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority"
DeleteReposIssuesIssueFieldValueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values/{issue_field_id}"

// Pull request endpoints
GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls"
Expand Down
76 changes: 75 additions & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -2179,6 +2179,17 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
issueRequest.Type = github.Ptr(issueType)
}

// fallbackDeleteFieldIDs holds field IDs we need to clear via the dedicated
// REST DELETE endpoint after the PATCH lands. This is the omitempty-trap
// fallback: when the merged list is empty AND we have deletions to make,
// `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 short-circuits, and nothing
// gets cleared. When the merged list is non-empty the PATCH's set semantics
// handle the deletes implicitly (current - new), so no DELETE follow-up is
// needed in that path.
var fallbackDeleteFieldIDs []int64

if len(issueFieldValues) > 0 || len(fieldIDsToDelete) > 0 {
// The REST update endpoint uses "set" semantics — it overwrites all existing
// field values with whatever is sent. Fetch the current values first, merge in
Expand All @@ -2201,7 +2212,27 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
}
merged = kept
}
issueRequest.IssueFieldValues = merged
if len(merged) == 0 && len(fieldIDsToDelete) > 0 {
// Omitempty trap: skip the IssueFieldValues assignment so we don't
// rely on a value that's about to be stripped from the JSON anyway,
// and clear each field via the dedicated DELETE endpoint after the
// PATCH lands. Only queue a DELETE for fields actually present on
// the issue — the dotcom DELETE endpoint returns 404 for absent
// values, and we want to preserve the pre-fix behaviour of treating
// "delete a field that isn't set" as a silent no-op (which is what
// happens when the user idempotently re-runs the same call).
existingIDs := make(map[int64]bool, len(existing))
for _, e := range existing {
existingIDs[e.FieldID] = true
}
for _, id := range fieldIDsToDelete {
if existingIDs[id] {
fallbackDeleteFieldIDs = append(fallbackDeleteFieldIDs, id)
}
}
} else {
issueRequest.IssueFieldValues = merged
}
}

updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
Expand All @@ -2222,6 +2253,49 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil
}

// Clear any fields whose deletion the PATCH couldn't carry. See the
// fallbackDeleteFieldIDs declaration above for the omitempty trap details.
// We hit the dedicated DELETE endpoint per field — it takes the integer
// field ID, and the URL is the standard `/repos/{owner}/{repo}` pattern.
//
// Per-field errors don't short-circuit: each DELETE is attempted, and any
// failures are aggregated into a single error response that names the
// successful and failed field IDs. This avoids leaving the caller blind
// to which deletions landed when one of several fails (e.g. transient
// 5xx on field 2 of 3 — without aggregation, field 1 is already gone and
// field 3 was never attempted, but the caller only sees a generic error).
if len(fallbackDeleteFieldIDs) > 0 {
var failedIDs, succeededIDs []int64
var firstFailureErr error
var firstFailureResp *github.Response
for _, fieldID := range fallbackDeleteFieldIDs {
path := fmt.Sprintf("repos/%s/%s/issues/%d/issue-field-values/%d", owner, repo, issueNumber, fieldID)
req, err := client.NewRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
failedIDs = append(failedIDs, fieldID)
if firstFailureErr == nil {
firstFailureErr = err
}
continue
}
delResp, err := client.Do(req, nil)
if err != nil {
failedIDs = append(failedIDs, fieldID)
if firstFailureErr == nil {
firstFailureErr = err
firstFailureResp = delResp
}
continue
}
succeededIDs = append(succeededIDs, fieldID)
_ = delResp.Body.Close()
}
if len(failedIDs) > 0 {
msg := fmt.Sprintf("failed to clear issue field values: failed=%v, cleared=%v", failedIDs, succeededIDs)
return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil
}
}

// Use GraphQL API for state updates
if state != "" {
// Mandate specifying duplicateOf when trying to close as duplicate
Expand Down
Loading
Loading