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