[Phase 4] Persist mentioned-from-description work item relations

What does this MR do and why?

Phase 4 of persisting MR ↔️ Work Item relationships (see #601174).

Phase 3 exposed the persisted merge_requests_closing_issues rows over GraphQL as workItemRelations. In review of the frontend MR (!240762, note 3467447255), Deepika found a parity gap: an issue mentioned but not closed in the MR description appears under the old derived linkedWorkItems field (computed live via MergeRequest#issues_mentioned_but_not_closing) but was missing from the new persisted workItemRelations.

Root cause: MergeRequest#cache_merge_request_closes_issues! only persisted closing issues (link_type: :closes, from_mr_description: true); mentioned-but-not-closing issues were never persisted. This MR persists them so the two fields agree.

Stacked MR. Targets the Phase 3 branch 601174-phase3-graphql (MR !238500), not master. Review/merge earlier phases first.

Changes

  • cache_merge_request_closes_issues! now also persists issues mentioned-but-not-closing in the description as from_mr_description: true, link_type: :mentioned rows, computed from the freshly extracted closing set (referenced − closes) so a given issue is never both a closes and a mentioned row.
  • The promote/insert helpers and the from-description delete now operate on the unscoped merge_request_issues association (the merge_request_closing_issues association is scoped to closes) and are scoped by link_type, so mentioned rows are managed alongside closes rows — and the closes promotion no longer picks up a user-created mentioned row for the same issue.
  • Model validation relaxed: from_mr_description may now be true for closes or mentioned (both description-derived); only related (user-created only) is rejected.

Read-time authorization is unchanged: workItemRelations filters per row by the existing read_merge_request_closing_issue policy, the same as closing rows.

Database review

All operations are keyed on a single merge_request_id and bounded by the number of issues referenced in one MR description. The from-description rebuild runs inside the existing transaction in cache_merge_request_closes_issues!.

Delete (now spans link types — previously closes only):

DELETE FROM merge_requests_closing_issues
WHERE merge_request_id = ? AND from_mr_description = TRUE;

Promote user-created rows for a link type (per closes and per mentioned):

SELECT issue_id FROM merge_requests_closing_issues
WHERE merge_request_id = ? AND from_mr_description = FALSE AND link_type = ? AND issue_id IN (?);

UPDATE merge_requests_closing_issues SET from_mr_description = TRUE
WHERE merge_request_id = ? AND link_type = ? AND issue_id IN (?);

Insert the remaining rows — reuses the existing bulk_insert! path already used for closes (now also for link_type: :mentioned).

All filter on merge_request_id (+ link_type/issue_id), covered by existing indexes. No new index or migration.

Verified on a Database Lab production clone (snapshot 2026-06-18)

Worst-case MR by row count (~2,960 from-description rows), EXPLAIN (ANALYZE, BUFFERS), warm cache. Counts only, no row data; literal id shown as <mr>. The DELETE was measured inside a rolled-back transaction.

Promote SELECT (run once per closes and per mentioned) — index seek on the partial unique index, no extra rows scanned:

Index Scan using index_mr_closing_issues_on_mr_id_issue_id_link_type
  (cost=0.12..2.60 rows=1) (actual rows=0 loops=1)
  Index Cond: ((merge_request_id = <mr>) AND (issue_id = ANY (...)) AND (link_type = 1))
  Filter: (NOT from_mr_description)
  Buffers: shared hit=2
  Execution Time: 0.034 ms

The UPDATE that promotes matched rows uses the same (merge_request_id, link_type, issue_id) access path plus a small write, so it is equally bounded.

From-description DELETE (now spans closes + mentioned) — index scan on merge_request_id, filtering from_mr_description:

Delete on merge_requests_closing_issues  (actual rows=0 loops=1)
  ->  Index Scan using index_mr_closing_issues_on_merge_request_id_and_link_type
        (cost=0.43..2556.83 rows=2614) (actual rows=2962 loops=1)
        Index Cond: (merge_request_id = <mr>)
        Filter: from_mr_description
        Buffers: shared hit=90
  Execution Time: 3.15 ms

The added per-call cost is one Gitlab::ReferenceExtractor#analyze over the title + description (already run for linkedWorkItems, just not on write) plus the bounded promote/insert above. Requesting database review.

How to set up and validate locally

bundle exec rspec \
  spec/models/merge_request_spec.rb -e "cache_merge_request_closes_issues" \
  spec/models/merge_requests_closing_issues_spec.rb \
  spec/requests/api/graphql/merge_request/work_item_relations_parity_spec.rb

Manual: create an MR whose description references #N without a closing keyword, then query both linkedWorkItems and workItemRelations — the mentioned item now appears in both.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Edited by Jorge Tomás

Merge request reports

Loading