Persist MR ↔️ Work Item relationships (new GraphQL field + mutations)

Context

Related to epic gitlab-org/plan-stage#456 — Related Work Items widget in MRs.

Today, MergeRequest.linkedWorkItems is an experimental GraphQL field (milestone 18.10) that returns two kinds of links:

  • CLOSES rows persisted in merge_requests_closing_issues (auto-populated by MergeRequest#cache_merge_request_closes_issues! via Gitlab::ClosingIssueExtractor).
  • MENTIONED items derived on-the-fly via MergeRequest#issues_mentioned_but_not_closing → Gitlab::ReferenceExtractor. Nothing is persisted.

The resolver returns a PORO with no linkId, so the sidebar widget at app/assets/javascripts/sidebar/components/related_work_items/related_work_items.vue is read-only — users must edit the description to add/remove links.

This issue persists MR↔️WI relationships in the database so each link has a stable id, exposes a new GraphQL field that returns persistent relation rows, deprecates the existing experimental linkedWorkItems field, adds mutations for create/destroy, and surfaces an add/remove UI in the existing sidebar widget.

Scope (v1)

  • Link types: closes and mentioned only — parity with current behavior. Enum is designed to grow (implements reserved).
  • Cross-namespace WI linking supported.
  • New GraphQL field workItemRelations added; existing linkedWorkItems deprecated but unchanged at runtime during the deprecation window.
  • Behind feature flag mr_work_item_relations (development, default off).

Out of scope (follow-ups)

  • implements link type
  • autoCloseOnMerge per-link
  • Create-WI-and-link in a single mutation
  • WI-side manual MR linking (UI on the work item development widget)
  • Group/epic targets
  • Description-save hook to persist mentioned rows automatically
  • Renaming merge_requests_closing_issues → merge_request_work_items (#456869 (closed))
  • External-issue parity on the new field
  • Removal of the deprecated linkedWorkItems field

DB strategy — Option B (extend merge_requests_closing_issues)

Two options were evaluated; Option B is the proposed implementation because cache_merge_request_closes_issues! is touched in many code paths (AfterCreateService, UpdateService, RefreshService, ReopenService) and a single-table model avoids dual-writes. The follow-up rename to merge_request_work_items is already on the roadmap (#456869 (closed)) and absorbs Option B naturally. To re-confirm with @mcelicalderon.

Migrations:

  • Regular migration adds link_type smallint NOT NULL DEFAULT 0 and namespace_id bigint.
  • Post-deploy: batched backfill of namespace_id; FK to namespaces; CHECK namespace_id IS NOT NULL (validated async). Drop the model-level (merge_request_id, issue_id) uniqueness and add a new unique index (merge_request_id, issue_id, link_type) — the WI may legitimately be both closes and implements later (also addresses TODO at app/models/merge_request.rb:3041).

Enum on the model:

enum :link_type, { closes: 0, mentioned: 1 }, prefix: true
# Reserved: implements: 2 (future)

from_mr_description only applies when link_type = 0 (closes); model validation enforces false for other types.

Implementation slices

1. Model layer

app/models/merge_requests_closing_issues.rb:

  • Add enum :link_type, belongs_to :namespace, validates :merge_request_id, uniqueness: { scope: [:issue_id, :link_type] }.
  • Validation: from_mr_description == false unless link_type_closes?.
  • New scopes: for_link_type(types), user_created (from_mr_description: false).

app/models/merge_request.rb:

  • cached_closes_issues association scoped to .link_type_closes.
  • Scope the wipe in cache_merge_request_closes_issues! (line 1861) and update_cached_closing_issues_from_description! (line 3018) to .link_type_closes. Manual closes (from_mr_description: false) and all mentioned rows are untouched.
  • bulk_insert_cached_closing_issues (line 3029) sets link_type: :closes explicitly.

Audit any other reader (e.g., Issue#merge_requests_count at app/models/issue.rb:714) that assumes "all rows in the table mean closes" — add .link_type_closes.

New PORO app/models/merge_requests/work_item_relation.rb for the new GraphQL field (existing MergeRequests::LinkedWorkItem stays while the deprecated field still uses it).

2. Service layer

New, under app/services/merge_requests/work_item_relations/:

  • base_service.rb (shared helpers)
  • create_service.rb — input target_work_items: [WorkItem], link_type. Authorizes :admin_merge_request_work_item_relation on the MR, then per-item :read_work_item. find_or_initialize_by(merge_request:, issue_id:, link_type:); sets from_mr_description: false, project_id, namespace_id.
  • destroy_service.rb — input ids: [Integer] (relation row IDs, batch). Authorizes :admin_merge_request_work_item_relation. Only deletes rows with from_mr_description: false.

Do not inherit from IssuableLinks::CreateService (assumes symmetric source/target). Use app/services/work_items/related_work_item_links/ as a structural reference.

3. Authorization

New ability :admin_merge_request_work_item_relation:

  • app/policies/merge_request_policy.rb — enable under the existing rule { can?(:admin_merge_request) } block.
  • app/policies/merge_requests_closing_issues_policy.rb — add a per-row rule for the destroy service.

Cross-namespace: per-item :read_work_item is checked in the create service.

4. GraphQL

New type — app/graphql/types/merge_requests/work_item_relation_type.rb:

graphql_name 'MergeRequestWorkItemRelation'
field :id, ::Types::GlobalIDType[::MergeRequestsClosingIssues], null: false
field :link_type, ::Types::MergeRequests::WorkItemLinkTypeEnum, null: false
field :work_item, ::Types::WorkItemType, null: true
field :from_mr_description, GraphQL::Types::Boolean, null: false

External issues are intentionally excluded — the persistence layer only models internal WI relationships. External issues remain on the deprecated field.

New field on Types::MergeRequestType:

field :work_item_relations, [::Types::MergeRequests::WorkItemRelationType],
  null: true, calls_gitaly: false,
  resolver: ::Resolvers::MergeRequests::WorkItemRelationsResolver

Deprecate the existing linked_work_items field at app/graphql/types/merge_request_type.rb:320:

deprecated: { reason: 'Use `workItemRelations` instead.', milestone: '<current>' }

Runtime behavior of linked_work_items is unchanged during the deprecation window.

New resolver — app/graphql/resolvers/merge_requests/work_item_relations_resolver.rb: reads merge_requests_closing_issues filtered by link_type and visibility; caps at 500; maps rows to MergeRequests::WorkItemRelation.

Mutations under app/graphql/mutations/merge_requests/work_item_relations/:

  • create.rb — graphql_name 'CreateMergeRequestWorkItemRelation'. Args: merge_request_id, work_item_ids: [GID!] (max 10), link_type: WorkItemLinkTypeEnum (default MENTIONED). Returns merge_request, work_item_relations, errors.
  • destroy.rb — graphql_name 'DestroyMergeRequestWorkItemRelations'. Args: merge_request_id, ids: [GID!] (relation IDs, batch — per Natalia's note "would make sense to use link id here because that would be unique"). Returns merge_request, removed_ids, errors.

Register both in app/graphql/types/mutation_type.rb.

5. Frontend

app/assets/javascripts/sidebar/components/related_work_items/related_work_items.vue:

  • Migrate to the new workItemRelations field (id, linkType, workItem, fromMrDescription).
  • Show "Add" affordance when userPermissions.adminMergeRequest.
  • Per-item remove (×) when fromMrDescription === false && canAdmin; calls destroyMergeRequestWorkItemRelations.
  • Auto-derived items (fromMrDescription: true) remain read-only.

Update query app/assets/javascripts/sidebar/queries/merge_request_related_work_items.query.graphql to use the new field.

New form app/assets/javascripts/sidebar/components/related_work_items/related_work_items_add_form.vue:

  • Mirror app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue.
  • Reuse ~/work_items/components/shared/work_item_token_input.vue for cross-namespace WI search.
  • 2-option radio group: MENTIONED (default), CLOSES. When CLOSES is selected and mr.targetBranch !== project.defaultBranch, surface help text noting auto-close requires the default branch.
  • Cache update via Apollo immer.produce (pattern: work_item_add_relationship_form.vue:91-140).

New GraphQL files:

  • create_merge_request_work_item_relation.mutation.graphql
  • destroy_merge_request_work_item_relations.mutation.graphql

Out of scope for v1: WI-side manual MR linking on app/assets/javascripts/work_items/components/work_item_development/work_item_development.vue.

6. Feature flag

config/feature_flags/development/mr_work_item_relations.yml — type: development, default off, group group::project management.

  • Flag off: new field returns null, mutations fail authorization, frontend keeps reading the deprecated linkedWorkItems, no add/remove UI.
  • Flag on: new field active, mutations active, frontend reads workItemRelations.
  • Frontend gating via glFeatures.mrWorkItemRelations.

Rollout: enable on gitlab-org/gitlab first; .com staged rollout; remove the flag in milestone +2. The deprecated field's removal is a separate, later step coordinated with the experiment graduation.

Critical files

  • app/models/merge_requests_closing_issues.rb
  • app/models/merge_request.rb (.link_type_closes scoping)
  • app/graphql/types/merge_request_type.rb (new field + deprecation)
  • app/graphql/types/merge_requests/work_item_relation_type.rb (new)
  • app/graphql/resolvers/merge_requests/work_item_relations_resolver.rb (new)
  • app/graphql/mutations/merge_requests/work_item_relations/{create,destroy}.rb (new)
  • app/services/merge_requests/work_item_relations/{create,destroy,base}_service.rb (new)
  • app/policies/merge_request_policy.rb, app/policies/merge_requests_closing_issues_policy.rb (new ability)
  • app/assets/javascripts/sidebar/components/related_work_items/related_work_items.vue (migrate, add UI)
  • app/assets/javascripts/sidebar/components/related_work_items/related_work_items_add_form.vue (new)
  • app/assets/javascripts/sidebar/queries/merge_request_related_work_items.query.graphql
  • DB migrations under db/migrate/ and db/post_migrate/

Verification

  1. Migrate locally; confirm db/structure.sql shows link_type smallint NOT NULL DEFAULT 0 and namespace_id bigint on merge_requests_closing_issues.
  2. Feature.enable(:mr_work_item_relations).
  3. GraphiQL — create:
mutation {
  createMergeRequestWorkItemRelation(input: {
    mergeRequestId: "gid://gitlab/MergeRequest/1",
    workItemIds: ["gid://gitlab/WorkItem/42", "gid://gitlab/WorkItem/43"],
    linkType: MENTIONED
  }) {
    workItemRelations { id linkType workItem { id title } fromMrDescription }
    errors
  }
}
  1. Query and confirm both fields work (new returns persisted rows, deprecated keeps current behavior).
  2. Destroy with the returned relation id.
  3. UI smoke test: visit an MR page; Add button is present, form links a WI, remove (×) hides the row optimistically; description-derived Closes #N rows have no remove button.
  4. Cross-namespace: link a WI in a different project that the user can read.
  5. Tests: extend spec/requests/api/graphql/merge_request/linked_work_items_spec.rb (deprecation annotation); new spec/requests/api/graphql/merge_request/work_item_relations_spec.rb; new mutation request specs, service specs, policy specs, model specs, Vue widget specs.

/cc @mcelicalderon for DB strategy review (Option B vs. A).

Edited by 🤖 GitLab Bot 🤖