Persist MR ↔ Work Item relationships (new GraphQL field + mutations)
<!--IssueSummary start-->
<details>
<summary>
Everyone can contribute. [Help move this issue forward](https://handbook.gitlab.com/handbook/marketing/developer-relations/contributor-success/community-contributors-workflows/#contributor-links) while earning points, leveling up and collecting rewards.
</summary>
- [Label this issue](https://contributors.gitlab.com/manage-issue?action=label&projectId=278964&issueIid=601174)
</details>
<!--IssueSummary end-->
## Context
Related to epic [gitlab-org/plan-stage#456 — Related Work Items widget in MRs](https://gitlab.com/groups/gitlab-org/plan-stage/-/work_items/456).
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` (gitlab-org/gitlab#456869)
- 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 (gitlab-org/gitlab#456869) 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:
```ruby
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`:
```ruby
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`:
```ruby
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`:
```ruby
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:
```graphql
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
}
}
```
4. Query and confirm both fields work (new returns persisted rows, deprecated keeps current behavior).
5. Destroy with the returned relation `id`.
6. 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.
7. Cross-namespace: link a WI in a different project that the user can read.
8. 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).
issue