diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index b58703c3e2b4f7605aa85f75dbc12f2ae3cf08f4..d2f93ac98dca64bd812cdb167b675bbeb3051fb0 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -9,6 +9,8 @@ class WorkItemType < BaseObject authorize :read_work_item + present_using WorkItemPresenter + field :author, Types::UserType, null: true, description: 'User that created the work item.', experiment: { milestone: '15.9' } @@ -63,6 +65,11 @@ class WorkItemType < BaseObject description: 'Whether the work item belongs to an archived project. Always false for group level work items.', experiment: { milestone: '16.5' } + field :duplicated_to_work_item_url, GraphQL::Types::String, null: true, + description: 'URL of the work item that the work item is marked as a duplicate of.' + field :moved_to_work_item_url, GraphQL::Types::String, null: true, + description: 'URL of the work item that the work item was moved to.' + markdown_field :title_html, null: true markdown_field :description_html, null: true @@ -74,10 +81,6 @@ def work_item_type object.work_item_type end - def web_url - Gitlab::UrlBuilder.build(object) - end - def create_note_email object.creatable_note_email_address(context[:current_user]) end @@ -89,3 +92,5 @@ def archived end end end + +Types::WorkItemType.prepend_mod_with('Types::WorkItemType') diff --git a/app/presenters/work_item_presenter.rb b/app/presenters/work_item_presenter.rb index 995f2d02156d70f1d46d5ca933585e563979265b..ddd966839a1921d2a7b6362e7463a395adac7440 100644 --- a/app/presenters/work_item_presenter.rb +++ b/app/presenters/work_item_presenter.rb @@ -1,4 +1,27 @@ # frozen_string_literal: true -class WorkItemPresenter < IssuePresenter # rubocop:todo Gitlab/NamespacedClass +class WorkItemPresenter < IssuePresenter # rubocop:todo Gitlab/NamespacedClass -- WorkItem is not namespaced + presents ::WorkItem, as: :work_item + + def duplicated_to_work_item_url + return unless work_item.duplicated? + return unless allowed_to_read_work_item?(work_item.duplicated_to) + + Gitlab::UrlBuilder.build(work_item.duplicated_to) + end + + def moved_to_work_item_url + return unless work_item.moved? + return unless allowed_to_read_work_item?(work_item.moved_to) + + Gitlab::UrlBuilder.build(work_item.moved_to) + end + + private + + def allowed_to_read_work_item?(item) + Ability.allowed?(current_user, :read_work_item, item) + end end + +WorkItemPresenter.prepend_mod_with('WorkItemPresenter') diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 6613b5fd6350a1ad4bc4e35e0b6e312825944a54..f8bab183c227025bfbd5e3efe81a311d0d0c8575 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -37744,12 +37744,15 @@ four standard [pagination arguments](#pagination-arguments): | <a id="workitemcreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp of when the work item was created. | | <a id="workitemdescription"></a>`description` | [`String`](#string) | Description of the work item. | | <a id="workitemdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `description`. | +| <a id="workitemduplicatedtoworkitemurl"></a>`duplicatedToWorkItemUrl` | [`String`](#string) | URL of the work item that the work item is marked as a duplicate of. | | <a id="workitemid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | | <a id="workitemiid"></a>`iid` | [`String!`](#string) | Internal ID of the work item. | | <a id="workitemlockversion"></a>`lockVersion` | [`Int!`](#int) | Lock version of the work item. Incremented each time the work item is updated. | +| <a id="workitemmovedtoworkitemurl"></a>`movedToWorkItemUrl` | [`String`](#string) | URL of the work item that the work item was moved to. | | <a id="workitemname"></a>`name` | [`String`](#string) | Name or title of this object. | | <a id="workitemnamespace"></a>`namespace` **{warning-solid}** | [`Namespace`](#namespace) | **Introduced** in GitLab 15.10. **Status**: Experiment. Namespace the work item belongs to. | | <a id="workitemproject"></a>`project` **{warning-solid}** | [`Project`](#project) | **Introduced** in GitLab 15.3. **Status**: Experiment. Project the work item belongs to. | +| <a id="workitempromotedtoepicurl"></a>`promotedToEpicUrl` | [`String`](#string) | URL of the epic that the work item has been promoted to. | | <a id="workitemstate"></a>`state` | [`WorkItemState!`](#workitemstate) | State of the work item. | | <a id="workitemtitle"></a>`title` | [`String!`](#string) | Title of the work item. | | <a id="workitemtitlehtml"></a>`titleHtml` | [`String`](#string) | GitLab Flavored Markdown rendering of `title`. | diff --git a/ee/app/graphql/ee/types/work_item_type.rb b/ee/app/graphql/ee/types/work_item_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..b095b57a219fc139c94ba10b00fec060bcbed7b5 --- /dev/null +++ b/ee/app/graphql/ee/types/work_item_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EE + module Types + module WorkItemType # rubocop:disable Gitlab/BoundedContexts -- Types::WorkItemType is CE class + extend ActiveSupport::Concern + + prepended do + field :promoted_to_epic_url, GraphQL::Types::String, null: true, + description: 'URL of the epic that the work item has been promoted to.' + end + end + end +end diff --git a/ee/app/presenters/ee/work_item_presenter.rb b/ee/app/presenters/ee/work_item_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..f981b59e9c7e3394f9687804374fc2f8f5c43f1d --- /dev/null +++ b/ee/app/presenters/ee/work_item_presenter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module EE + module WorkItemPresenter + extend ActiveSupport::Concern + + def promoted_to_epic_url + return unless work_item.promoted? + return unless Ability.allowed?(current_user, :read_epic, work_item.promoted_to_epic) + + ::Gitlab::UrlBuilder.build(work_item.promoted_to_epic) + end + end +end diff --git a/ee/spec/graphql/types/work_item_type_spec.rb b/ee/spec/graphql/types/work_item_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c35957de25ea232c8f718dd025ba1d5a126556b6 --- /dev/null +++ b/ee/spec/graphql/types/work_item_type_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['WorkItem'], feature_category: :team_planning do + include GraphqlHelpers + + it { expect(described_class).to have_graphql_field(:promotedToEpicUrl) } +end diff --git a/ee/spec/presenters/ee/work_item_presenter_spec.rb b/ee/spec/presenters/ee/work_item_presenter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a57a3712aeac7754ddc1aa9d8a8222b36b88dccd --- /dev/null +++ b/ee/spec/presenters/ee/work_item_presenter_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItemPresenter, feature_category: :team_planning do + let(:user) { build_stubbed(:user) } + let(:group) { build_stubbed(:group, :private) } + let(:work_item) { build_stubbed(:work_item) } + let(:epic) { build_stubbed(:epic, group: group) } + let(:epic_url) { Gitlab::UrlBuilder.build(epic) } + + subject(:presenter) { described_class.new(work_item, current_user: user) } + + describe '#promoted_to_epic_url' do + before do + stub_licensed_features(epics: true) + end + + subject { presenter.promoted_to_epic_url } + + it { is_expected.to be_nil } + + context 'when promoted_to is set' do + let(:work_item) { build_stubbed(:work_item, promoted_to_epic: epic) } + + context 'when anonymous' do + let(:user) { nil } + + it { is_expected.to be_nil } + end + + context 'with signed in user' do + before do + stub_member_access_level(group, access_level => user) if access_level + end + + context 'when user has no role in namespace' do + let(:access_level) { nil } + + it { is_expected.to be_nil } + end + + context 'when user has guest role in namespace' do + let(:access_level) { :guest } + + it { is_expected.to eq(epic_url) } + end + + context 'when user has reporter role in namespace' do + let(:access_level) { :reporter } + + it { is_expected.to eq(epic_url) } + end + + context 'when user has developer role in namespace' do + let(:access_level) { :developer } + + it { is_expected.to eq(epic_url) } + end + end + end + end +end diff --git a/spec/graphql/types/work_item_type_spec.rb b/spec/graphql/types/work_item_type_spec.rb index 86515e1fd9e63a13000a28698a5cc8c1c4eeacc6..f7760ea118fd25fefb7b887353bbc6e899416844 100644 --- a/spec/graphql/types/work_item_type_spec.rb +++ b/spec/graphql/types/work_item_type_spec.rb @@ -35,9 +35,11 @@ reference archived name + duplicatedToWorkItemUrl + movedToWorkItemUrl ] - expect(described_class).to have_graphql_fields(*fields) + expect(described_class).to have_graphql_fields(*fields).at_least end describe 'pagination and count' do diff --git a/spec/presenters/work_item_presenter_spec.rb b/spec/presenters/work_item_presenter_spec.rb index 522ffd832c18d173eddfc13df2ac1cd91ab07499..635939d6e9bd895c5d52d9052960b1ceda8e9f0e 100644 --- a/spec/presenters/work_item_presenter_spec.rb +++ b/spec/presenters/work_item_presenter_spec.rb @@ -3,12 +3,74 @@ require 'spec_helper' RSpec.describe WorkItemPresenter, feature_category: :portfolio_management do - let(:work_item) { build_stubbed(:work_item) } + let(:user) { build_stubbed(:user) } + let(:project) { build_stubbed(:project) } + let(:original_work_item) { build_stubbed(:work_item, project: project) } + let(:target_work_item) { build_stubbed(:work_item, project: project) } + let(:target_work_item_url) { Gitlab::UrlBuilder.build(target_work_item) } - it 'presents a work item and uses methods defined in IssuePresenter' do - user = build_stubbed(:user) - presenter = work_item.present(current_user: user) + subject(:presenter) { described_class.new(original_work_item, current_user: user) } + it 'presents a work item and uses methods defined in IssuePresenter' do expect(presenter.issue_path).to eq(presenter.web_path) end + + shared_examples 'returns target work item url based on permissions' do + context 'when anonymous' do + let(:user) { nil } + + it { is_expected.to be_nil } + end + + context 'with signed in user' do + before do + stub_member_access_level(project, access_level => user) if access_level + end + + context 'when user has no role in project' do + let(:access_level) { nil } + + it { is_expected.to be_nil } + end + + context 'when user has guest role in project' do + let(:access_level) { :guest } + + it { is_expected.to eq(target_work_item_url) } + end + + context 'when user has reporter role in project' do + let(:access_level) { :reporter } + + it { is_expected.to eq(target_work_item_url) } + end + + context 'when user has developer role in project' do + let(:access_level) { :developer } + + it { is_expected.to eq(target_work_item_url) } + end + end + end + + describe '#duplicated_to_work_item_url' do + subject { presenter.duplicated_to_work_item_url } + + it { is_expected.to be_nil } + + it_behaves_like 'returns target work item url based on permissions' do + let(:original_work_item) { build_stubbed(:work_item, project: project, duplicated_to: target_work_item) } + end + end + + describe '#moved_to_work_item_url' do + subject { presenter.moved_to_work_item_url } + + it { is_expected.to be_nil } + + it_behaves_like 'returns target work item url based on permissions' do + # Create original work item in other project + let(:original_work_item) { build_stubbed(:work_item, moved_to: target_work_item) } + end + end end