Guard against missing legacy epic_issues row in EpicIssue entity

What/why

GET /api/v4/groups/:id/epics/:epic_iid/issues reads children from work_item_parent_links, but ee/lib/api/entities/epic_issue.rb still serialises the legacy epic_issues row id via issue.parent_link.epic_issue.id.

When a parent link has no legacy row, that association is nil and .id raises NoMethodError. This happens during collection serialisation, so one bad child 500s the whole page and makes every other issue in the epic unreachable.

Fix: issue.parent_link.epic_issue&.id, plus a regression test.

This is hardening only, it does not fix what creates parent links without the legacy row. Root cause is identified and tracked in the linked issue; a separate MR will address it.

Behaviour change

epic_issue_id can now be null where the request previously failed outright. Null was chosen over dropping the child so callers still get the issue and its other fields. Clients that strictly type this field could be affected, but their current behaviour is a 500.

Note: relative_position on the next line was deliberately left unguarded — it reads parent_link.relative_position and never touches the missing association.

The JSON schema fixture was widened to ["integer", "null"]. The rest of the reformatting in that file comes from scripts/lint-json -f, required by the pre-push hook, not a hand edit.

Before After
612058-before-fix-500 612058-after-fix-200

How to set up and validate locally

Use the Rails console to create an epic with one healthy child and one orphaned child (a parent link with no legacy epic_issues row), then hit the endpoint directly:

# gdk rails console
user     = User.find_by_username('root')
group    = Group.find_by_full_path('<your-group>')
project  = Project.find_by_full_path('<your-group>/<your-project>')
provider = ::WorkItems::TypesFramework::Provider.new

epic_wi  = ::WorkItems::CreateService.new(container: group, current_user: user,
  params: { title: 'orphan-demo', work_item_type: provider.find_by_base_type(:epic) }).execute[:work_item]
adjacent = ::WorkItems::CreateService.new(container: project, current_user: user,
  params: { title: 'adjacent', work_item_type: provider.find_by_base_type(:issue) }).execute[:work_item]
victim   = ::WorkItems::CreateService.new(container: project, current_user: user,
  params: { title: 'victim', work_item_type: provider.find_by_base_type(:issue) }).execute[:work_item]

# healthy child: unpositioned add goes through ParentLinks::CreateService
::WorkItems::ParentLinks::CreateService.new(epic_wi, user, { target_issuable: adjacent }).execute

# orphaned child: positioned add goes through ParentLinks::ReorderService, which
# creates the parent link without the legacy epic_issues row
::WorkItems::UpdateService.new(container: project, current_user: user, params: {},
  widget_params: { hierarchy_widget: { parent: epic_wi, adjacent_work_item: adjacent,
                                       relative_position: 'BEFORE' } }).execute(victim)

victim.reset.parent_link.epic_issue  # => nil, this is the orphan
puts epic_wi.sync_object.iid         # epic iid for the request below
  • Then call the endpoint: curl --header "PRIVATE-TOKEN: <token>" "https://gdk.test:3000/api/v4/groups/<your-group>/epics/<epic_iid>/issues"
  • On master: 500, with NoMethodError: undefined method 'id' for nil at ee/lib/api/entities/epic_issue.rb
  • On this branch: 200, the orphaned child serialises "epic_issue_id": null, the healthy child keeps its id
  • Specs: bundle exec rspec ee/spec/requests/api/epic_issues_spec.rb

References

Edited by Daniyal Arshad

Merge request reports

Loading
Loading