Skip to content

issue_write update loses concurrent issue field changes (read-merge-PATCH with no precondition) #3751

Open

Description

@rafe-walker

Describe the bug

When issue_write (method: update) sets or clears org issue field values, updateIssue in pkg/github/issues.go:

  1. reads all current field values over GraphQL (fetchExistingIssueFieldValues, issueFieldValues(first: 25)),
  2. merges the requested change into that snapshot (mergeIssueFieldValues),
  3. sends the whole merged set in the issue PATCH (issueRequest.IssueFieldValues → patchIssue).

The issue PATCH replaces the whole field set, and nothing checks that the snapshot is still current. When two updates to the same issue overlap, each one writes back the other field's old value, so whichever finishes last reverts the other's change. Both calls report success.

Deletes hit the same problem: deleting one of several fields PATCHes the remaining snapshot back.

A smaller, separate problem: the read takes only the first 25 values (first: 25), so on an issue with more than 25 values, the rest are not in the merged set.

Affected version

Reproduced against v2.1.0 (commit 4bcdcdf8). The same code is on main at 99d6d6503a7b2a133e2f8a595d6d0ba24b4086d3.

Steps to reproduce the behavior

  1. In an org with issue fields, take an issue with a single-select field Priority and a date field Target date, both set.
  2. Send two issue_write calls at the same time against that issue:
    • A: {"method":"update","owner":"<org>","repo":"<repo>","issue_number":N,"issue_fields":[{"field_name":"Priority","field_option_name":"P2"}]}
    • B: {"method":"update","owner":"<org>","repo":"<repo>","issue_number":N,"issue_fields":[{"field_name":"Target date","value":"2026-12-01"}]}
  3. Read the issue's field values back. Run this several times.

Expected vs actual behavior

Expected: both changes stay: Priority = P2 and Target date = 2026-12-01.

Actual: often only one change survives. The call that finishes last PATCHes back the other field's old value.

Observed: 19 of 30 concurrent pairs lost an update, over 3 runs. As a control, the same pairs sent as per-field GraphQL setIssueFieldValue mutations (the path the feature-flagged granular set_issue_fields tool uses) lost 0 of 28.

Suggested fix: never send field values in the issue PATCH. Send only the requested values to the additive POST /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values endpoint, and clear fields with the per-field DELETE …/issue-field-values/{field_id} that updateIssue already uses (treating 404 as already cleared). This also drops the extra GraphQL read and the 25-value cap on this path. Another option is to reuse the per-field GraphQL setIssueFieldValue path from set_issue_fields. A patch for the REST option is ready if that helps.

Logs

Not applicable. A deterministic unit test shows the stale snapshot being written back. On main it fails with this PATCH body after setting only Priority (101):

{"issue_field_values":[{"field_id":101,"value":"P2"},{"field_id":202,"value":"2026-01-01"}]}

Found during an independent audit of v2.1.0 on a throwaway org and private test repo. Prepared with AI assistance (Claude Code); reproduced by two independent test runs before filing.

Activity

  1. rafe-walker commented on Nov 9, 2026

    @rafe-walker
    Author

    Before opening a PR, I'd like to check the approach with you, because the fix runs into a design point raised on #2755.

    On #2755 the preference was for issue_write to "call the updateIssue endpoint only, and do all the mutations for an issue in one operation." I don't think that can be made safe here. The issue PATCH has set semantics for issue_field_values, so any single PATCH that carries fields has to carry all of them, which means reading them first, and the gap between that read and the write is the race. Without a precondition on the PATCH (an ETag or version), one call can't avoid it.

    I have a patch that keeps the issue PATCH but takes fields out of it:

    • requested values go to the additive POST /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values
    • clears use the per-field DELETE …/issue-field-values/{field_id} that updateIssue already uses
    • the GraphQL read and merge are removed (and with them the first: 25 cap on this path)

    Tested live against a build of current main with the patch, same setup as the report:

    Build Concurrent set vs set Clear vs set
    v2.1.0 2 of 6 pairs lost an update —
    patched 0 of 12 0 of 3

    The trade-off is that a call changing both issue properties and fields is now two requests instead of one. To keep the common failure (an invalid field value) all-or-nothing, the field writes can go before the PATCH, and if anything partially applies, the error says exactly what was saved.

    Two other things worth knowing:

    Would you accept this direction? If you'd rather keep everything in one PATCH (e.g. if the endpoint can take a precondition), or route fields through the GraphQL setIssueFieldValue mutation that set_issue_fields uses, I'm happy to rework it that way before opening the PR.

    Prepared with AI assistance (Claude Code).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions