diff --git a/doc/api/resource_state_events.md b/doc/api/resource_state_events.md index b2e886618d508b8586c7a11c21db3bf1f22ffe64..8e957df8145dd7ed266de0104cce29ed07860d9a 100644 --- a/doc/api/resource_state_events.md +++ b/doc/api/resource_state_events.md @@ -8,8 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35210/) in GitLab 13.2. -Resource state events keep track of what happens to GitLab [issues](../user/project/issues/index.md) and -[merge requests](../user/project/merge_requests/index.md). +Resource state events keep track of what happens to GitLab [issues](../user/project/issues/index.md) +[merge requests](../user/project/merge_requests/index.md) and [epics starting with GitLab 15.4](../user/group/epics/index.md) Use them to track which state was set, who did it, and when it happened. @@ -212,3 +212,105 @@ Example response: "state": "closed" } ``` + +## Epics + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97554) in GitLab 15.4. + +### List group epic state events + +Returns a list of all state events for a single epic. + +```plaintext +GET /groups/:id/epics/:epic_id/resource_state_events +``` + +| Attribute | Type | Required | Description | +|-------------| -------------- | -------- |--------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding). | +| `epic_id` | integer | yes | The ID of an epic. | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/groups/5/epics/11/resource_state_events" +``` + +Example response: + +```json +[ + { + "id": 142, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-20T13:38:20.077Z", + "resource_type": "Epic", + "resource_id": 11, + "state": "opened" + }, + { + "id": 143, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-21T14:38:20.077Z", + "resource_type": "Epic", + "resource_id": 11, + "state": "closed" + } +] +``` + +### Get single epic state event + +Returns a single state event for a specific group epic. + +```plaintext +GET /groups/:id/epics/:epic_id/resource_state_events/:resource_state_event_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +|---------------------------| -------------- | -------- |-------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding). | +| `epic_id` | integer | yes | The ID of an epic. | +| `resource_state_event_id` | integer | yes | The ID of a state event. | + +Example request: + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/groups/5/epics/11/resource_state_events/143" +``` + +Example response: + +```json +{ + "id": 143, + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.example.com/root" + }, + "created_at": "2018-08-21T14:38:20.077Z", + "resource_type": "Epic", + "resource_id": 11, + "state": "closed" +} +``` diff --git a/ee/lib/ee/api/helpers/resource_label_events_helpers.rb b/ee/lib/ee/api/helpers/resource_events_helpers.rb similarity index 58% rename from ee/lib/ee/api/helpers/resource_label_events_helpers.rb rename to ee/lib/ee/api/helpers/resource_events_helpers.rb index d699cab6a4e93693ba93538dc938574ff6133fe2..76a0bff77edc2d112b36acc7798cdd85e47b7f91 100644 --- a/ee/lib/ee/api/helpers/resource_label_events_helpers.rb +++ b/ee/lib/ee/api/helpers/resource_events_helpers.rb @@ -3,16 +3,16 @@ module EE module API module Helpers - module ResourceLabelEventsHelpers + module ResourceEventsHelpers extend ActiveSupport::Concern class_methods do extend ::Gitlab::Utils::Override - override :feature_category_per_eventable_type - def feature_category_per_eventable_type + override :eventable_types + def eventable_types super.merge!( - ::Epic => :portfolio_management + ::Epic => { feature_category: :portfolio_management, id_field: 'ID' } ) end end diff --git a/ee/spec/factories/resource_state_event.rb b/ee/spec/factories/resource_state_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..edd2b50c54e1c2688d9c840c641b57781ac62999 --- /dev/null +++ b/ee/spec/factories/resource_state_event.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.modify do + factory :resource_state_event do + issue { nil } + merge_request { nil } + epic { issue.nil? && merge_request.nil? ? association(:epic) : nil } + state { :opened } + user { issue&.author || merge_request&.author || epic&.author || association(:user) } + end +end diff --git a/ee/spec/helpers/timeboxes_helper_spec.rb b/ee/spec/helpers/timeboxes_helper_spec.rb index 03645cd2f32157114993be2c5e021a1df3491477..6cc1df0fac8b1027f4d1a88a5e03a7f9979a3820 100644 --- a/ee/spec/helpers/timeboxes_helper_spec.rb +++ b/ee/spec/helpers/timeboxes_helper_spec.rb @@ -77,6 +77,8 @@ end describe '#legacy_milestone?' do + let_it_be(:issue) { create(:issue) } + subject { legacy_milestone?(milestone) } describe 'without any ResourceStateEvents' do @@ -89,7 +91,7 @@ let(:milestone) { double('Milestone', created_at: Date.current) } before do - create_resource_state_event(Date.yesterday) + create_resource_state_event(issue, Date.yesterday) end it { is_expected.to eq(false) } @@ -99,7 +101,7 @@ let(:milestone) { double('Milestone', created_at: Date.current) } before do - create_resource_state_event + create_resource_state_event(issue) end it { is_expected.to eq(false) } @@ -109,7 +111,7 @@ let(:milestone) { double('Milestone', created_at: Date.yesterday) } before do - create_resource_state_event + create_resource_state_event(issue) end it { is_expected.to eq(true) } @@ -156,8 +158,8 @@ end end - def create_resource_state_event(created_at = Date.current) - create(:resource_state_event, created_at: created_at) + def create_resource_state_event(issue, created_at = Date.current) + create(:resource_state_event, issue: issue, created_at: created_at) end def stub_can_admin_milestone(ability) diff --git a/ee/spec/requests/api/resource_state_events_spec.rb b/ee/spec/requests/api/resource_state_events_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..16b85d5fc3652fc19a943278dcefe132e703df71 --- /dev/null +++ b/ee/spec/requests/api/resource_state_events_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::ResourceStateEvents do + let_it_be(:user) { create(:user) } + + before do + parent.add_developer(user) + end + + context 'when eventable is an Epic' do + before do + parent.add_owner(user) + stub_licensed_features(epics: true) + end + + it_behaves_like 'resource_state_events API', 'groups', 'epics', 'id' do + let(:parent) { create(:group, :public) } + let(:eventable) { create(:epic, group: parent, author: user) } + end + end +end diff --git a/lib/api/helpers/resource_events_helpers.rb b/lib/api/helpers/resource_events_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..c47a58e8fce3a19da0b073e8f89ceed7de424fbd --- /dev/null +++ b/lib/api/helpers/resource_events_helpers.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module API + module Helpers + module ResourceEventsHelpers + def self.eventable_types + # This is a method instead of a constant, allowing EE to more easily extend it. + { + Issue => { feature_category: :team_planning, id_field: 'IID' }, + MergeRequest => { feature_category: :code_review, id_field: 'IID' } + } + end + end + end +end + +API::Helpers::ResourceEventsHelpers.prepend_mod_with('API::Helpers::ResourceEventsHelpers') diff --git a/lib/api/helpers/resource_label_events_helpers.rb b/lib/api/helpers/resource_label_events_helpers.rb deleted file mode 100644 index eeb68362c1da25098cfd97b9d41b866834b4fbc4..0000000000000000000000000000000000000000 --- a/lib/api/helpers/resource_label_events_helpers.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module API - module Helpers - module ResourceLabelEventsHelpers - def self.feature_category_per_eventable_type - # This is a method instead of a constant, allowing EE to more easily - # extend it. - { - Issue => :team_planning, - MergeRequest => :code_review - } - end - end - end -end - -API::Helpers::ResourceLabelEventsHelpers.prepend_mod_with('API::Helpers::ResourceLabelEventsHelpers') diff --git a/lib/api/resource_label_events.rb b/lib/api/resource_label_events.rb index cd56809f45ad48832e746450222a20b8971cc3d6..e74b6509a17a9c93e0058e3021bb78b14e60f70e 100644 --- a/lib/api/resource_label_events.rb +++ b/lib/api/resource_label_events.rb @@ -7,20 +7,22 @@ class ResourceLabelEvents < ::API::Base before { authenticate! } - Helpers::ResourceLabelEventsHelpers.feature_category_per_eventable_type.each do |eventable_type, feature_category| + Helpers::ResourceEventsHelpers.eventable_types.each do |eventable_type, details| parent_type = eventable_type.parent_class.to_s.underscore eventables_str = eventable_type.to_s.underscore.pluralize + human_eventable_str = eventable_type.to_s.underscore.humanize.downcase + feature_category = details[:feature_category] params do requires :id, type: String, desc: "The ID of a #{parent_type}" end resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc "Get a list of #{eventable_type.to_s.downcase} resource label events" do + desc "Get a list of #{human_eventable_str} resource label events" do success Entities::ResourceLabelEvent detail 'This feature was introduced in 11.3' end params do - requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable' + requires :eventable_id, types: [Integer, String], desc: "The #{details[:id_field]} of the #{human_eventable_str}" use :pagination end @@ -32,13 +34,13 @@ class ResourceLabelEvents < ::API::Base present ResourceLabelEvent.visible_to_user?(current_user, paginate(events)), with: Entities::ResourceLabelEvent end - desc "Get a single #{eventable_type.to_s.downcase} resource label event" do + desc "Get a single #{human_eventable_str} resource label event" do success Entities::ResourceLabelEvent detail 'This feature was introduced in 11.3' end params do requires :event_id, type: String, desc: 'The ID of a resource label event' - requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable' + requires :eventable_id, types: [Integer, String], desc: "The #{details[:id_field]} of the #{human_eventable_str}" end get ":id/#{eventables_str}/:eventable_id/resource_label_events/:event_id", feature_category: feature_category do eventable = find_noteable(eventable_type, params[:eventable_id]) diff --git a/lib/api/resource_state_events.rb b/lib/api/resource_state_events.rb index 4b92f320d6f4d91f6416452b6c7179d04f50cb90..f817d55c505bdc5a484e17b5e9b495f5561e34d9 100644 --- a/lib/api/resource_state_events.rb +++ b/lib/api/resource_state_events.rb @@ -7,41 +7,41 @@ class ResourceStateEvents < ::API::Base before { authenticate! } - { - Issue => :team_planning, - MergeRequest => :code_review - }.each do |eventable_class, feature_category| - eventable_name = eventable_class.to_s.underscore + Helpers::ResourceEventsHelpers.eventable_types.each do |eventable_type, details| + parent_type = eventable_type.parent_class.to_s.underscore + eventables_str = eventable_type.to_s.underscore.pluralize + human_eventable_str = eventable_type.to_s.underscore.humanize.downcase + feature_category = details[:feature_category] params do - requires :id, type: String, desc: "The ID of a project" + requires :id, type: String, desc: "The ID of a #{parent_type}" end - resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc "Get a list of #{eventable_class.to_s.downcase} resource state events" do + resource parent_type.pluralize.to_sym, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc "Get a list of #{human_eventable_str} resource state events" do success Entities::ResourceStateEvent end params do - requires :eventable_iid, types: Integer, desc: "The IID of the #{eventable_name}" + requires :eventable_id, types: Integer, desc: "The #{details[:id_field]} of the #{human_eventable_str}" use :pagination end - get ":id/#{eventable_name.pluralize}/:eventable_iid/resource_state_events", feature_category: feature_category, urgency: :low do - eventable = find_noteable(eventable_class, params[:eventable_iid]) + get ":id/#{eventables_str}/:eventable_id/resource_state_events", feature_category: feature_category, urgency: :low do + eventable = find_noteable(eventable_type, params[:eventable_id]) events = ResourceStateEventFinder.new(current_user, eventable).execute present paginate(events), with: Entities::ResourceStateEvent end - desc "Get a single #{eventable_class.to_s.downcase} resource state event" do + desc "Get a single #{human_eventable_str} resource state event" do success Entities::ResourceStateEvent end params do - requires :eventable_iid, types: Integer, desc: "The IID of the #{eventable_name}" + requires :eventable_id, types: Integer, desc: "The #{details[:id_field]} of the #{human_eventable_str}" requires :event_id, type: Integer, desc: 'The ID of a resource state event' end - get ":id/#{eventable_name.pluralize}/:eventable_iid/resource_state_events/:event_id", feature_category: feature_category do - eventable = find_noteable(eventable_class, params[:eventable_iid]) + get ":id/#{eventables_str}/:eventable_id/resource_state_events/:event_id", feature_category: feature_category do + eventable = find_noteable(eventable_type, params[:eventable_id]) event = ResourceStateEventFinder.new(current_user, eventable).find(params[:event_id]) diff --git a/spec/requests/api/resource_state_events_spec.rb b/spec/requests/api/resource_state_events_spec.rb index 46ca9874395e8a4a4659897aa03a7cf2fe980a47..5f756bc6c63f0fc45d9bbea20fd306d2bcf39f25 100644 --- a/spec/requests/api/resource_state_events_spec.rb +++ b/spec/requests/api/resource_state_events_spec.rb @@ -6,87 +6,8 @@ let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, :public, namespace: user.namespace) } - before_all do - project.add_developer(user) - end - - shared_examples 'resource_state_events API' do |parent_type, eventable_type, id_name| - describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_state_events" do - let!(:event) { create_event } - - it "returns an array of resource state events" do - url = "/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events" - get api(url, user) - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.first['id']).to eq(event.id) - expect(json_response.first['state']).to eq(event.state.to_s) - end - - it "returns a 404 error when eventable id not found" do - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{non_existing_record_id}/resource_state_events", user) - - expect(response).to have_gitlab_http_status(:not_found) - end - - it "returns 404 when not authorized" do - parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - private_user = create(:user) - - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events", private_user) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_state_events/:event_id" do - let!(:event) { create_event } - - it "returns a resource state event by id" do - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events/#{event.id}", user) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['id']).to eq(event.id) - expect(json_response['state']).to eq(event.state.to_s) - end - - it "returns 404 when not authorized" do - parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - private_user = create(:user) - - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events/#{event.id}", private_user) - - expect(response).to have_gitlab_http_status(:not_found) - end - - it "returns a 404 error if resource state event not found" do - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events/#{non_existing_record_id}", user) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - describe 'pagination' do - # https://gitlab.com/gitlab-org/gitlab/-/issues/220192 - it 'returns the second page' do - create_event - event2 = create_event - - get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_state_events?page=2&per_page=1", user) - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(response.headers['X-Total']).to eq '2' - expect(json_response.count).to eq(1) - expect(json_response.first['id']).to eq(event2.id) - end - end - - def create_event(state: :opened) - create(:resource_state_event, eventable.class.name.underscore => eventable, state: state) - end + before do + parent.add_developer(user) end context 'when eventable is an Issue' do diff --git a/spec/support/shared_examples/requests/api/resource_state_events_api_shared_examples.rb b/spec/support/shared_examples/requests/api/resource_state_events_api_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..c1850a0d0c95cd293be6652eeb11515bec89efbe --- /dev/null +++ b/spec/support/shared_examples/requests/api/resource_state_events_api_shared_examples.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'resource_state_events API' do |parent_type, eventable_type, id_name| + let(:base_path) { "/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}" } + + describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_state_events" do + let!(:event) { create_event } + + it "returns an array of resource state events" do + url = "#{base_path}/resource_state_events" + get api(url, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['id']).to eq(event.id) + expect(json_response.first['state']).to eq(event.state.to_s) + end + + it "returns a 404 error when eventable id not found" do + get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{non_existing_record_id}/resource_state_events", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + private_user = create(:user) + + get api("#{base_path}/resource_state_events", private_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_state_events/:event_id" do + let!(:event) { create_event } + + it "returns a resource state event by id" do + get api("#{base_path}/resource_state_events/#{event.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(event.id) + expect(json_response['state']).to eq(event.state.to_s) + end + + it "returns 404 when not authorized" do + parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + private_user = create(:user) + + get api("#{base_path}/resource_state_events/#{event.id}", private_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it "returns a 404 error if resource state event not found" do + get api("#{base_path}/resource_state_events/#{non_existing_record_id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'pagination' do + # https://gitlab.com/gitlab-org/gitlab/-/issues/220192 + it 'returns the second page' do + create_event + event2 = create_event + + get api("#{base_path}/resource_state_events?page=2&per_page=1", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq '2' + expect(json_response.count).to eq(1) + expect(json_response.first['id']).to eq(event2.id) + end + end + + def create_event(state: :opened) + create(:resource_state_event, eventable.class.name.underscore => eventable, state: state) + end +end diff --git a/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb b/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb index 716bee39fcae130335414a613dd95ef44ceb6cb9..a7e51408032dd29d2a89af582368f5db98947f0f 100644 --- a/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb +++ b/spec/support/shared_examples/services/resource_events/synthetic_notes_builder_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'filters by paginated notes' do |event_type| - let(:event) { create(event_type) } # rubocop:disable Rails/SaveBang + let(:event) { create(event_type, issue: create(:issue)) } before do create(event_type, issue: event.issue)