Reset current_status association after state-change status update

What does this MR do and why?

Fixes a bug where work item status changes triggered by closing or reopening an issue were missing from the webhook changes payload.

Root cause

EE::Issues::BaseService#update_status_after_state_change delegates the status write to WorkItems::Widgets::Statuses::UpdateService. That service calls ensure_work_item, which loads a fresh WorkItem instance when it receives an Issue. The new status is written to that separate object. The original issue object retains its stale cached current_status has-one association.

When the update hook fires (via IssuableBaseService#update with a state_event: 'close'), hook_association_changes computes the diff:

if supports_status? && (old_current_status(old_associations) != status_with_fallback)
  changes[:status] = [...]
end

old_associations[:status] was captured before the update (correct old value). But issue.status_with_fallback reads current_status — which is still the stale cached old value. old == new, so changes[:status] is never emitted.

The stale association is directly observable in the Rails console:

issue.status_with_fallback&.name           # => "In dev"   (stale after close)
issue.association(:current_status).reset
issue.status_with_fallback&.name           # => "Complete"  (correct)

Fix

Add sync_original_association to WorkItems::Widgets::Statuses::UpdateService. The service stores the original caller as @original and, after writing the new status, resets @original.association(:current_status) only when the original object differs from the WorkItem instance the service actually wrote to. This is detected via object identity (@original.equal?(work_item)), not a class check - so it's robust to any caller type.

WorkItem callers are the same object the service writes to, so no reset happens - preserving previous_changes that after_update_commit callbacks depend on. Issue callers get the reset so the webhook diff builder reads the new status.

def sync_original_association
  return if @original.equal?(work_item)
  return unless @original.respond_to?(:current_status)

  @original.association(:current_status).reset
end

Alternatives considered

  • Reset in base_service.rb with unless issue.is_a?(WorkItem) guard. This worked but placed knowledge of UpdateService's internal ensure_work_item behavior in the wrong layer.
  • Make ensure_work_item not swap instances. Rejected: too much blast radius, the service is called from many places that legitimately pass a WorkItem.
  • Add status to the close hook's old_associations. Wrong locus: the close hook carries no association diff; the correct fix is at the update hook, which already carries old_associations[:status].

References

Local validation

The bug only triggers when a plain Issue is passed (not a WorkItem) because ensure_work_item loads a fresh WorkItem instance only in that case. The stale association is then observable directly:

project = Project.find_by_full_path('your-group/your-project')
user = project.first_owner
issue = Issue.create!(project: project, title: 'test', author: user)

WorkItems::Statuses::CurrentStatus.create!(work_item_id: issue.id, system_defined_status_id: 2)
issue.association(:current_status).reset
puts issue.status_with_fallback&.name  # => "In progress"

# Capture old status — same as associations_before_update does
old_status = issue.status_with_fallback

Issues::CloseService.new(container: project, current_user: user).execute(issue)

# Simulate what hook_association_changes sees when building the webhook diff
hook_data = issue.to_hook_data(user, old_associations: { status: old_status }, action: 'update')
sc = hook_data[:changes][:status]
puts sc ? "previous=#{sc[:previous]['name']} current=#{sc[:current]['name']}" : "NIL - bug present"
# After fix: previous=In progress current=Done

The reopen path is symmetric — start from a closed issue with a done-category status and use Issues::ReopenService.

Run the specs:

bundle exec rspec \
  ee/spec/services/work_items/widgets/statuses/update_service_spec.rb \
  ee/spec/services/work_items/callbacks/status_spec.rb \
  ee/spec/services/ee/issues/close_service_spec.rb \
  ee/spec/services/ee/issues/reopen_service_spec.rb \
  ee/spec/services/ee/issues/update_service_spec.rb

MR acceptance checklist

  • This MR follows the MR acceptance checklist
  • Tests added (update_service_spec, close_service_spec, reopen_service_spec)
  • Rubocop clean
  • No DB migrations
  • EE-only change
Edited by Marc Saleiko

Merge request reports

Loading
Loading