[Phase 3] Persist and expose MR ↔️ work item relations over GraphQL
What does this MR do and why?
Phase 3 of persisting MR
Exposes the persisted relations (the link_type column added in Phase 1, written by the services in Phase 2) over GraphQL, deprecates the experimental derived linkedWorkItems field in favour of the new persisted one, and persists the mentioned-from-description relations so the two fields agree.
Stack status. Phases 1 and 2 are merged to master, so this now targets
master. The former Phase 4 (persist mentioned-from-description relations, !241259 (merged)) has been squash-merged into this branch and is included here. The model rename (Phase 5, !239615) stacks on top of this MR.
Feature flag
explicit_mr_work_item_relations(wip, default off,group::project management). The mutations, the relations resolver, and the work item creation widget are gated on this flag — the same one the frontend uses (!238648 (merged)), consolidated per gitlab-org/plan-stage#456.- While disabled: the resolver returns
null, both relation mutations raise resource-not-available, and theworkItemCreatedevelopment widget is a no-op. Persisting mentioned-from-description rows is not flag-gated (it is a cache-population concern); they are simply not exposed until the flag is on.
Read surface
- New
MergeRequestWorkItemRelationtype (connection), backed directly bymerge_requests_closing_issuesrows — no PORO, mirroring the existingWorkItemClosingMergeRequesttype on the work item side. Authorized per row by the existingread_merge_request_closing_issuepolicy, so confidential/unreadable work items are filtered out of the connection.- Fields:
id(global ID),linkType,fromMrDescription,workItem(batch-loaded).
- Fields:
- New
workItemRelationsfield onMergeRequestType, capped atmax_page_size: 500, with an optionaltypes:filter. linkedWorkItemsis deprecated (Use 'workItemRelations' instead, 19.2); its runtime is unchanged during the deprecation window.
Mutations introduced by this MR
| Mutation | Arguments | Behaviour |
|---|---|---|
mergeRequestCreateWorkItemRelations |
projectPath, iid, workItemIds: [WorkItemID!]!, linkType (default RELATED) |
Links existing work items to an MR. Delegates to MergeRequests::WorkItemRelations::CreateService; returns the created workItemRelations and per-item errors. MENTIONED is rejected (see below). |
mergeRequestDestroyWorkItemRelations |
projectPath, iid, ids: [MergeRequestsClosingIssuesID!]! (relation global IDs) |
Removes persisted relations. Delegates to MergeRequests::WorkItemRelations::DestroyService; returns removedRelationIds. |
workItemCreate (extended) |
new optional developmentWidget: { mergeRequestIds: [MergeRequestID!]!, linkType } |
Links a newly created work item to one or more MRs in the same call — see below. |
Both dedicated mutations are Mutations::MergeRequests::WorkItemRelations::{Create,Destroy} and authorize the granular update_merge_request token scope.
MENTIONEDis internal-only.mentionedrelations are derived automatically from the MR description/title (see Persist mentioned-from-description relations below) and are not user-creatable.MergeRequests::WorkItemRelations::CreateServicerejectslink_type: :mentionedwith abad_requesterror, so both the mutation and theworkItemCreatedevelopment widget refuse it. The mutation'slinkTypenow defaults toRELATED(wasMENTIONED).
Link relations at work item creation time
Requested in review on the frontend MR (!240762#note_3462809191): when a user creates a work item from an MR context, establish the link in the same workItemCreate call instead of forcing a follow-up mergeRequestCreateWorkItemRelations.
- New
Types::WorkItems::Widgets::DevelopmentCreateInputType(WorkItemWidgetDevelopmentCreateInput):mergeRequestIds: [MergeRequestID!]!(capped atMergeRequests::WorkItemRelations::BaseService::MAX_RELATIONS, referenced directly so the limit is not duplicated) andlinkType(defaultsRELATED). - New
WorkItems::Callbacks::Development, auto-dispatched by the widget framework (viaWidget.callback_class, no registration file needed). It reusesMergeRequests::WorkItemRelations::CreateServiceonce per MR, so idempotency, the relation limit, and the granularcreate_merge_request_work_item_relationauthorization are unchanged — creating a work item cannot link it to an MR you can't manage. - Gated per MR on
explicit_mr_work_item_relations(actor:merge_request.project), consistent with the rest of the backend.
Persist mentioned-from-description relations (folded-in former Phase 4)
The derived linkedWorkItems field shows issues mentioned but not closed in the MR description (computed live via MergeRequest#issues_mentioned_but_not_closing). The persisted workItemRelations previously only saw closing rows, so mentioned issues were missing from it (reported on !240762#note_3467447255).
MergeRequest#cache_merge_request_issues!(renamed fromcache_merge_request_closes_issues!, since it now caches more than closes) also persists mentioned-but-not-closing issues asfrom_mr_description: true, link_type: :mentionedrows, computed against the freshly extracted closing set so an issue is never both aclosesand amentionedrow.- The promote/insert helpers and the from-description delete use the unscoped
merge_request_issuesassociation (themerge_request_closing_issuesassociation is scoped tocloses) and are scoped bylink_type, so 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.
Other
by_link_typesscope on the relations model (used by the resolver'stypes:filter) — see Database review below.- Regenerated GraphQL reference docs and introspection schemas.
Database review
All operations are keyed on a single merge_request_id and bounded by the number of issues referenced in one MR description. Verified on a Database Lab production clone (snapshot 2026-06-18), worst-case MR (~2,960 from-description rows), EXPLAIN (ANALYZE, BUFFERS), warm cache. Counts only, no row data; literal id shown as <mr>.
Read — by_link_types (resolver types: filter):
SELECT * FROM merge_requests_closing_issues WHERE merge_request_id = ? AND link_type IN (?);Index Scan using index_mr_closing_issues_on_merge_request_id_and_link_type
(cost=0.43..3036.00 rows=3111) (actual rows=2962 loops=1)
Index Cond: ((merge_request_id = <mr>) AND (link_type = ANY ('{0,1,2}'::integer[])))
Buffers: shared hit=90
Execution Time: 0.817 msWrite — from-description rebuild in cache_merge_request_issues! (runs in the existing transaction on MR create/update/refresh; all four statements keyed on merge_request_id).
- Delete the existing from-description rows (now spans
closes+mentioned):
DELETE FROM merge_requests_closing_issues WHERE merge_request_id = ? AND from_mr_description = TRUE;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 (actual rows=2962 loops=1)
Index Cond: (merge_request_id = <mr>) Filter: from_mr_description
Buffers: shared hit=90 — Execution Time: 3.15 ms- Find user-created rows to promote, per link type (
pluck):
SELECT issue_id FROM merge_requests_closing_issues
WHERE merge_request_id = ? AND from_mr_description = FALSE AND link_type = ? AND issue_id IN (?);Index Scan using index_mr_closing_issues_on_mr_id_issue_id_link_type (actual rows=0 loops=1)
Index Cond: ((merge_request_id = <mr>) AND (issue_id = ANY (...)) AND (link_type = 1))
Buffers: shared hit=2 — Execution Time: 0.034 ms- Promote them to from-description (
update_all):
UPDATE merge_requests_closing_issues SET from_mr_description = TRUE
WHERE merge_request_id = ? AND link_type = ? AND issue_id IN (?);Update on merge_requests_closing_issues (actual rows=0 loops=1)
-> Index Scan using index_mr_closing_issues_on_mr_id_issue_id_link_type (actual rows=0 loops=1)
Index Cond: ((merge_request_id = <mr>) AND (issue_id = ANY (...)) AND (link_type = 1))
Buffers: shared hit=2 — Execution Time: 0.047 ms- Insert the remaining issues (
bulk_insert!, batches of 100) — reuses the existing closes path, now also forlink_type: :mentioned:
INSERT INTO merge_requests_closing_issues
(issue_id, merge_request_id, from_mr_description, link_type, created_at, updated_at)
VALUES (?, ?, TRUE, ?, ?, ?), ...;All three filtered statements are index seeks on merge_request_id (+ link_type/issue_id); the insert is a plain row insert. The added per-call cost is one Gitlab::ReferenceExtractor#analyze over the title + description (already run for linkedWorkItems, just not on write). No new index or migration. ~database review requested.
How to set up and validate locally
bundle exec rspec \
spec/graphql/types/merge_requests/work_item_relation_type_spec.rb \
spec/requests/api/graphql/merge_request/work_item_relations_spec.rb \
spec/requests/api/graphql/merge_request/work_item_relations_parity_spec.rb \
spec/requests/api/graphql/merge_request/linked_work_items_spec.rb \
spec/requests/api/graphql/mutations/merge_requests/work_item_relations/create_spec.rb \
spec/requests/api/graphql/mutations/merge_requests/work_item_relations/destroy_spec.rb \
spec/graphql/types/work_items/widgets/development_create_input_type_spec.rb \
spec/services/work_items/callbacks/development_spec.rb \
spec/requests/api/graphql/mutations/work_items/create_spec.rb \
spec/models/merge_requests_closing_issues_spec.rb \
spec/models/merge_request_spec.rb -e cache_merge_request_issuesSpecs cover: FF-on read (with link-type filtering and per-row authorization), FF-off returning null, create/destroy authorization + FF gating, the linkedWorkItems deprecation annotation, the workItemCreate development widget (links on create, FF-off no-op, over-limit error, unauthorized-MR error surfaced), the mentioned-from-description caching (persist/rebuild/promote/mutual-exclusion + relaxed validation), and linkedWorkItemsworkItemRelations parity for a mentioned issue.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.