diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 8f18c2cbce5..9c7931a4edb 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -35,6 +35,12 @@ module Resolvers private + def preloads + { + last_edited_by: :last_edited_by + } + end + # Allows to apply lookahead for fields # selected from WidgetInterface override :node_selection diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb index 4c365a67bfd..4861f7f46d8 100644 --- a/app/graphql/types/work_items/widgets/description_type.rb +++ b/app/graphql/types/work_items/widgets/description_type.rb @@ -13,8 +13,18 @@ module Types implements Types::WorkItems::WidgetInterface field :description, GraphQL::Types::String, - null: true, - description: 'Description of the work item.' + null: true, + description: 'Description of the work item.' + field :edited, GraphQL::Types::Boolean, + null: false, + description: 'Whether the description has been edited since the work item was created.', + method: :edited? + field :last_edited_at, Types::TimeType, + null: true, + description: 'Timestamp of when the work item\'s description was last edited.' + field :last_edited_by, Types::UserType, + null: true, + description: 'User that made the last edit to the work item\'s description.' markdown_field :description_html, null: true do |resolved_object| resolved_object.work_item diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 1e84d172bef..ec3b7957c79 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -3,7 +3,13 @@ module WorkItems module Widgets class Description < Base - delegate :description, to: :work_item + delegate :description, :edited?, :last_edited_at, to: :work_item + + def last_edited_by + return unless work_item.edited? + + work_item.last_edited_by + end end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index fb014d9522d..6db6184892b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -19367,6 +19367,9 @@ Represents a description widget. | ---- | ---- | ----------- | | `description` | [`String`](#string) | Description of the work item. | | `descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | +| `edited` | [`Boolean!`](#boolean) | Whether the description has been edited since the work item was created. | +| `lastEditedAt` | [`Time`](#time) | Timestamp of when the work item's description was last edited. | +| `lastEditedBy` | [`UserCore`](#usercore) | User that made the last edit to the work item's description. | | `type` | [`WorkItemWidgetType`](#workitemwidgettype) | Widget type. | ### `WorkItemWidgetHierarchy` diff --git a/doc/ci/runners/saas/linux_saas_runner.md b/doc/ci/runners/saas/linux_saas_runner.md index a6430b4b64d..a7d1b8722a5 100644 --- a/doc/ci/runners/saas/linux_saas_runner.md +++ b/doc/ci/runners/saas/linux_saas_runner.md @@ -18,7 +18,7 @@ For Free, Premium, and Ultimate plan customers, jobs on these instances consume | | Small | Medium | Large | |-------------------|---------------------------|---------------------------|--------------------------| | Specs | 1 vCPU, 3.75GB RAM | 2 vCPUs, 8GB RAM | 4 vCPUs, 16GB RAM | -| GitLab CI/CD tags | `saas-linux-medium-amd64` | `saas-linux-medium-amd64` | `saas-linux-large-amd64` | +| GitLab CI/CD tags | `saas-linux-small-amd64` | `saas-linux-medium-amd64` | `saas-linux-large-amd64` | | Subscription | Free, Premium, Ultimate | Free, Premium, Ultimate | Premium, Ultimate | The `small` machine type is the default. Your job runs on this machine type if you don't specify diff --git a/spec/factories/work_items.rb b/spec/factories/work_items.rb index 267ea9710b3..205b071a5d4 100644 --- a/spec/factories/work_items.rb +++ b/spec/factories/work_items.rb @@ -23,5 +23,9 @@ FactoryBot.define do issue_type { :incident } association :work_item_type, :default, :incident end + + trait :last_edited_by_user do + association :last_edited_by, factory: :user + end end end diff --git a/spec/graphql/types/work_item_type_spec.rb b/spec/graphql/types/work_item_type_spec.rb index c556424b0b4..11b02a88dbd 100644 --- a/spec/graphql/types/work_item_type_spec.rb +++ b/spec/graphql/types/work_item_type_spec.rb @@ -28,8 +28,6 @@ RSpec.describe GitlabSchema.types['WorkItem'] do closed_at ] - fields.each do |field_name| - expect(described_class).to have_graphql_fields(*fields) - end + expect(described_class).to have_graphql_fields(*fields) end end diff --git a/spec/graphql/types/work_items/widgets/description_type_spec.rb b/spec/graphql/types/work_items/widgets/description_type_spec.rb index 5ade1fe4aa2..aee388ce82a 100644 --- a/spec/graphql/types/work_items/widgets/description_type_spec.rb +++ b/spec/graphql/types/work_items/widgets/description_type_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Types::WorkItems::Widgets::DescriptionType do it 'exposes the expected fields' do - expected_fields = %i[description description_html type] + expected_fields = %i[description description_html edited last_edited_at last_edited_by type] expect(described_class).to have_graphql_fields(*expected_fields) end diff --git a/spec/models/work_items/widgets/description_spec.rb b/spec/models/work_items/widgets/description_spec.rb index 8359db31bff..c24dc9cfb9c 100644 --- a/spec/models/work_items/widgets/description_spec.rb +++ b/spec/models/work_items/widgets/description_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe WorkItems::Widgets::Description do - let_it_be(:work_item) { create(:work_item, description: '# Title') } + let_it_be(:user) { create(:user) } + let_it_be(:work_item, refind: true) do + create(:work_item, description: 'Title', last_edited_at: 10.days.ago, last_edited_by: user) + end describe '.type' do subject { described_class.type } @@ -22,4 +25,42 @@ RSpec.describe WorkItems::Widgets::Description do it { is_expected.to eq(work_item.description) } end + + describe '#edited?' do + subject { described_class.new(work_item).edited? } + + it { is_expected.to be_truthy } + end + + describe '#last_edited_at' do + subject { described_class.new(work_item).last_edited_at } + + it { is_expected.to eq(work_item.last_edited_at) } + end + + describe '#last_edited_by' do + subject { described_class.new(work_item).last_edited_by } + + context 'when the work item is edited' do + context 'when last edited user still exists in the DB' do + it { is_expected.to eq(user) } + end + + context 'when last edited user no longer exists' do + before do + work_item.update!(last_edited_by: nil) + end + + it { is_expected.to eq(User.ghost) } + end + end + + context 'when the work item is not edited yet' do + before do + work_item.update!(last_edited_at: nil) + end + + it { is_expected.to be_nil } + end + end end diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb index 1dd0046f782..69f8d1cac74 100644 --- a/spec/requests/api/graphql/project/work_items_spec.rb +++ b/spec/requests/api/graphql/project/work_items_spec.rb @@ -10,7 +10,10 @@ RSpec.describe 'getting an work item list for a project' do let_it_be(:current_user) { create(:user) } let_it_be(:item1) { create(:work_item, project: project, discussion_locked: true, title: 'item1') } - let_it_be(:item2) { create(:work_item, project: project, title: 'item2') } + let_it_be(:item2) do + create(:work_item, project: project, title: 'item2', last_edited_by: current_user, last_edited_at: 1.day.ago) + end + let_it_be(:confidential_item) { create(:work_item, confidential: true, project: project, title: 'item3') } let_it_be(:other_item) { create(:work_item) } @@ -75,6 +78,40 @@ RSpec.describe 'getting an work item list for a project' do end end + context 'when fetching description edit information' do + let(:fields) do + <<~GRAPHQL + nodes { + widgets { + type + ... on WorkItemWidgetDescription { + edited + lastEditedAt + lastEditedBy { + webPath + username + } + } + } + } + GRAPHQL + end + + it 'avoids N+1 queries' do + post_graphql(query, current_user: current_user) # warm-up + + control = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: current_user) + end + expect_graphql_errors_to_be_empty + + create_list(:work_item, 3, :last_edited_by_user, last_edited_at: 1.week.ago, project: project) + + expect_graphql_errors_to_be_empty + expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) + end + end + context 'when filtering by search' do it_behaves_like 'query with a search term' do let(:issuable_data) { items_data } diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 34644e5893a..e4bb4109c76 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -14,7 +14,10 @@ RSpec.describe 'Query.work_item(id)' do project: project, description: '- List item', start_date: Date.today, - due_date: 1.week.from_now + due_date: 1.week.from_now, + created_at: 1.week.ago, + last_edited_at: 1.day.ago, + last_edited_by: guest ) end @@ -67,6 +70,12 @@ RSpec.describe 'Query.work_item(id)' do ... on WorkItemWidgetDescription { description descriptionHtml + edited + lastEditedBy { + webPath + username + } + lastEditedAt } } GRAPHQL @@ -79,7 +88,13 @@ RSpec.describe 'Query.work_item(id)' do hash_including( 'type' => 'DESCRIPTION', 'description' => work_item.description, - 'descriptionHtml' => ::MarkupHelper.markdown_field(work_item, :description, {}) + 'descriptionHtml' => ::MarkupHelper.markdown_field(work_item, :description, {}), + 'edited' => true, + 'lastEditedAt' => work_item.last_edited_at.iso8601, + 'lastEditedBy' => { + 'webPath' => "/#{guest.full_path}", + 'username' => guest.username + } ) ) )