[Phase 3] Persist and expose MR ↔️ work item relations over GraphQL

What does this MR do and why?

Phase 3 of persisting MR ↔️ Work Item relationships (see #601174, Option B). Closes #601481.

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 the workItemCreate development 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 MergeRequestWorkItemRelation type (connection), backed directly by merge_requests_closing_issues rows — no PORO, mirroring the existing WorkItemClosingMergeRequest type on the work item side. Authorized per row by the existing read_merge_request_closing_issue policy, so confidential/unreadable work items are filtered out of the connection.
    • Fields: id (global ID), linkType, fromMrDescription, workItem (batch-loaded).
  • New workItemRelations field on MergeRequestType, capped at max_page_size: 500, with an optional types: filter.
  • linkedWorkItems is 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.

MENTIONED is internal-only. mentioned relations are derived automatically from the MR description/title (see Persist mentioned-from-description relations below) and are not user-creatable. MergeRequests::WorkItemRelations::CreateService rejects link_type: :mentioned with a bad_request error, so both the mutation and the workItemCreate development widget refuse it. The mutation's linkType now defaults to RELATED (was MENTIONED).

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 at MergeRequests::WorkItemRelations::BaseService::MAX_RELATIONS, referenced directly so the limit is not duplicated) and linkType (defaults RELATED).
  • New WorkItems::Callbacks::Development, auto-dispatched by the widget framework (via Widget.callback_class, no registration file needed). It reuses MergeRequests::WorkItemRelations::CreateService once per MR, so idempotency, the relation limit, and the granular create_merge_request_work_item_relation authorization 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 from cache_merge_request_closes_issues!, since it now caches more than closes) also persists mentioned-but-not-closing issues as from_mr_description: true, link_type: :mentioned rows, computed against the freshly extracted closing set so an issue is never both a closes and a mentioned row.
  • The promote/insert helpers and the from-description delete use the unscoped merge_request_issues association (the merge_request_closing_issues association is scoped to closes) and are scoped by link_type, so 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.

Other

  • by_link_types scope on the relations model (used by the resolver's types: 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 ms

Write — 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).

  1. 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
  1. 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
  1. 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
  1. Insert the remaining issues (bulk_insert!, batches of 100) — reuses the existing closes path, now also for link_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_issues

Specs 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 linkedWorkItems↔️workItemRelations parity for a mentioned issue.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Edited by Jorge Tomás

Merge request reports

Loading