[Phase 4] Persist mentioned-from-description work item relations
What does this MR do and why?
Phase 4 of persisting MR
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), notmaster. Review/merge earlier phases first.
Changes
cache_merge_request_closes_issues!now also persists issues mentioned-but-not-closing in the description asfrom_mr_description: true, link_type: :mentionedrows, computed from the freshly extracted closing set (referenced − closes) so a given issue is never both aclosesand amentionedrow.- The promote/insert helpers and the from-description delete now operate on the unscoped
merge_request_issuesassociation (themerge_request_closing_issuesassociation is scoped tocloses) and are scoped bylink_type, so mentioned rows are managed alongside closes rows — and the closes promotion no longer picks up a user-createdmentionedrow for the same issue. - Model validation relaxed:
from_mr_descriptionmay now betrueforclosesormentioned(both description-derived); onlyrelated(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 msThe 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 msThe 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.rbManual: 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.