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 byMergeRequest#cache_merge_request_closes_issues!viaGitlab::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 MRlinkedWorkItems field, adds mutations for create/destroy, and surfaces an add/remove UI in the existing sidebar widget.
Scope (v1)
- Link types:
closesandmentionedonly — parity with current behavior. Enum is designed to grow (implementsreserved). - Cross-namespace WI linking supported.
- New GraphQL field
workItemRelationsadded; existinglinkedWorkItemsdeprecated but unchanged at runtime during the deprecation window. - Behind feature flag
mr_work_item_relations(development, default off).
Out of scope (follow-ups)
implementslink typeautoCloseOnMergeper-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
mentionedrows automatically - Renaming
merge_requests_closing_issues→merge_request_work_items(#456869 (closed)) - External-issue parity on the new field
- Removal of the deprecated
linkedWorkItemsfield
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 0andnamespace_id bigint. - Post-deploy: batched backfill of
namespace_id; FK tonamespaces;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 bothclosesandimplementslater (also addresses TODO atapp/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 == falseunlesslink_type_closes?. - New scopes:
for_link_type(types),user_created(from_mr_description: false).
app/models/merge_request.rb:
cached_closes_issuesassociation scoped to.link_type_closes.- Scope the wipe in
cache_merge_request_closes_issues!(line 1861) andupdate_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) setslink_type: :closesexplicitly.
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— inputtarget_work_items: [WorkItem], link_type. Authorizes:admin_merge_request_work_item_relationon the MR, then per-item:read_work_item.find_or_initialize_by(merge_request:, issue_id:, link_type:); setsfrom_mr_description: false,project_id,namespace_id.destroy_service.rb— inputids: [Integer](relation row IDs, batch). Authorizes:admin_merge_request_work_item_relation. Only deletes rows withfrom_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 existingrule { 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: falseExternal 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::WorkItemRelationsResolverDeprecate 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(defaultMENTIONED). Returnsmerge_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"). Returnsmerge_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
workItemRelationsfield (id,linkType,workItem,fromMrDescription). - Show "Add" affordance when
userPermissions.adminMergeRequest. - Per-item remove (×) when
fromMrDescription === false && canAdmin; callsdestroyMergeRequestWorkItemRelations. - 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.vuefor cross-namespace WI search. - 2-option radio group:
MENTIONED(default),CLOSES. WhenCLOSESis selected andmr.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.graphqldestroy_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 deprecatedlinkedWorkItems, 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.rbapp/models/merge_request.rb(.link_type_closesscoping)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/anddb/post_migrate/
Verification
- Migrate locally; confirm
db/structure.sqlshowslink_type smallint NOT NULL DEFAULT 0andnamespace_id bigintonmerge_requests_closing_issues. Feature.enable(:mr_work_item_relations).- 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
}
}- Query and confirm both fields work (new returns persisted rows, deprecated keeps current behavior).
- Destroy with the returned relation
id. - UI smoke test: visit an MR page; Add button is present, form links a WI, remove (×) hides the row optimistically; description-derived
Closes #Nrows have no remove button. - Cross-namespace: link a WI in a different project that the user can read.
- Tests: extend
spec/requests/api/graphql/merge_request/linked_work_items_spec.rb(deprecation annotation); newspec/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).