Commit 4f2c8aa4 authored by Patrick Derichs's avatar Patrick Derichs Committed by Heinrich Lee Yu

Add state events to API

Also add specs for resource events with issuable method defined.
parent ded8a083
# frozen_string_literal: true
class ResourceStateEventFinder
def initialize(current_user, eventable)
@current_user = current_user
@eventable = eventable
end
def execute
return ResourceStateEvent.none unless can_read_eventable?
eventable.resource_state_events.includes(:user) # rubocop: disable CodeReuse/ActiveRecord
end
def can_read_eventable?
return unless eventable
Ability.allowed?(current_user, read_ability, eventable.project)
end
private
attr_reader :current_user, :eventable
def read_ability
:"read_#{eventable.class.to_ability_name}"
end
end
...@@ -12,4 +12,8 @@ class ResourceStateEvent < ResourceEvent ...@@ -12,4 +12,8 @@ class ResourceStateEvent < ResourceEvent
def self.issuable_attrs def self.issuable_attrs
%i(issue merge_request).freeze %i(issue merge_request).freeze
end end
def issuable
issue || merge_request
end
end end
---
title: Create API to retrieve resource state events
merge_request: 35210
author:
type: added
---
stage: Plan
group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Resource state events API
Resource state events keep track of what happens to GitLab [issues](../user/project/issues/) and
[merge requests](../user/project/merge_requests/).
Use them to track which state was set, who did it, and when it happened.
## Issues
### List project issue state events
Gets a list of all state events for a single issue.
```plaintext
GET /projects/:id/issues/:issue_iid/resource_state_events
```
| Attribute | Type | Required | Description |
| ----------- | -------------- | -------- | ------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `issue_iid` | integer | yes | The IID of an issue |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/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": "Issue",
"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": "Issue",
"resource_id": 11,
"state": "closed"
}
]
```
### Get single issue state event
Returns a single state event for a specific project issue
```plaintext
GET /projects/:id/issues/:issue_iid/resource_state_events/:resource_state_event_id
```
Parameters:
| Attribute | Type | Required | Description |
| ----------------------------- | -------------- | -------- | ------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path](README.md#namespaced-path-encoding) of the project |
| `issue_iid` | integer | yes | The IID of an issue |
| `resource_state_event_id` | integer | yes | The ID of a state event |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/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": "Issue",
"resource_id": 11,
"state": "closed"
}
```
## Merge requests
### List project merge request state events
Gets a list of all state events for a single merge request.
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/resource_state_events
```
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path](README.md#namespaced-path-encoding) of the project |
| `merge_request_iid` | integer | yes | The IID of a merge request |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/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": "MergeRequest",
"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": "MergeRequest",
"resource_id": 11,
"state": "closed"
}
]
```
### Get single merge request state event
Returns a single state event for a specific project merge request
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/resource_state_events/:resource_state_event_id
```
Parameters:
| Attribute | Type | Required | Description |
| ----------------------------- | -------------- | -------- | ------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `resource_state_event_id` | integer | yes | The ID of a state event |
Example request:
```shell
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/resource_state_events/120"
```
Example response:
```json
{
"id": 120,
"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": "MergeRequest",
"resource_id": 11,
"state": "closed"
}
```
...@@ -178,6 +178,7 @@ module API ...@@ -178,6 +178,7 @@ module API
mount ::API::Discussions mount ::API::Discussions
mount ::API::ResourceLabelEvents mount ::API::ResourceLabelEvents
mount ::API::ResourceMilestoneEvents mount ::API::ResourceMilestoneEvents
mount ::API::ResourceStateEvents
mount ::API::NotificationSettings mount ::API::NotificationSettings
mount ::API::Pages mount ::API::Pages
mount ::API::PagesDomains mount ::API::PagesDomains
......
# frozen_string_literal: true
module API
module Entities
class ResourceStateEvent < Grape::Entity
expose :id
expose :user, using: Entities::UserBasic
expose :created_at
expose :resource_type do |event, _options|
event.issuable.class.name
end
expose :resource_id do |event, _options|
event.issuable.id
end
expose :state
end
end
end
# frozen_string_literal: true
module API
class ResourceStateEvents < Grape::API::Instance
include PaginationParams
helpers ::API::Helpers::NotesHelpers
before { authenticate! }
[Issue, MergeRequest].each do |eventable_type|
parent_type = eventable_type.parent_class.to_s.underscore
eventables_str = eventable_type.to_s.underscore.pluralize
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 state events" do
success Entities::ResourceStateEvent
end
params do
requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable'
use :pagination
end
get ":id/#{eventables_str}/:eventable_id/resource_state_events" 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_type.to_s.downcase} resource state event" do
success Entities::ResourceStateEvent
end
params do
requires :event_id, type: String, desc: 'The ID of a resource state event'
requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable'
end
get ":id/#{eventables_str}/:eventable_id/resource_state_events/:event_id" do
eventable = find_noteable(eventable_type, params[:eventable_id])
not_found!('ResourceStateEvent') unless ResourceStateEventFinder.new(current_user, eventable).can_read_eventable?
event = eventable.resource_state_events.find(params[:event_id])
present event, with: Entities::ResourceStateEvent
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ResourceStateEventFinder do
let_it_be(:user) { create(:user) }
let_it_be(:issue_project) { create(:project) }
let_it_be(:issue) { create(:issue, project: issue_project) }
describe '#execute' do
subject { described_class.new(user, issue).execute }
it 'returns events accessible by user' do
event = create(:resource_state_event, issue: issue)
issue_project.add_guest(user)
expect(subject).to eq [event]
end
it 'filters events if issues and MRs are private' do
project = create(:project, :public, :issues_private, :merge_requests_private)
issue = create(:issue, project: project)
merge_request = create(:merge_request, source_project: project)
create(:resource_state_event, issue: issue)
create(:resource_state_event, merge_request: merge_request)
expect(subject).to be_empty
end
it 'filters events not accessible by user' do
project = create(:project, :private)
issue = create(:issue, project: project)
merge_request = create(:merge_request, source_project: project)
create(:resource_state_event, issue: issue)
create(:resource_state_event, merge_request: merge_request)
expect(subject).to be_empty
end
end
describe '#can_read_eventable?' do
let(:project) { create(:project, :private) }
subject { described_class.new(user, eventable).can_read_eventable? }
context 'when eventable is a Issue' do
let(:eventable) { create(:issue, project: project) }
context 'when issue is readable' do
before do
project.add_developer(user)
end
it { is_expected.to be_truthy }
end
context 'when issue is not readable' do
it { is_expected.to be_falsey }
end
end
context 'when eventable is a MergeRequest' do
let(:eventable) { create(:merge_request, source_project: project) }
context 'when merge request is readable' do
before do
project.add_developer(user)
end
it { is_expected.to be_truthy }
end
context 'when merge request is not readable' do
it { is_expected.to be_falsey }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::ResourceStateEvents do
let!(:user) { create(:user) }
let!(:project) { create(:project, :public, namespace: user.namespace) }
before do
project.add_developer(user)
end
context 'when eventable is an Issue' do
it_behaves_like 'resource_state_events API', 'projects', 'issues', 'iid' do
let(:parent) { project }
let(:eventable) { create(:issue, project: project, author: user) }
end
end
context 'when eventable is a Merge Request' do
it_behaves_like 'resource_state_events API', 'projects', 'merge_requests', 'iid' do
let(:parent) { project }
let(:eventable) { create(:merge_request, source_project: project, target_project: project, author: user) }
end
end
end
# frozen_string_literal: true
RSpec.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
end
...@@ -101,6 +101,16 @@ RSpec.shared_examples 'a resource event for issues' do ...@@ -101,6 +101,16 @@ RSpec.shared_examples 'a resource event for issues' do
expect(events).to be_empty expect(events).to be_empty
end end
end end
if described_class.method_defined?(:issuable)
describe '#issuable' do
let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue2) }
it 'returns the expected issuable' do
expect(event1.issuable).to eq(issue2)
end
end
end
end end
RSpec.shared_examples 'a resource event for merge requests' do RSpec.shared_examples 'a resource event for merge requests' do
...@@ -132,4 +142,14 @@ RSpec.shared_examples 'a resource event for merge requests' do ...@@ -132,4 +142,14 @@ RSpec.shared_examples 'a resource event for merge requests' do
expect(events).to be_empty expect(events).to be_empty
end end
end end
if described_class.method_defined?(:issuable)
describe '#issuable' do
let_it_be(:event1) { create(described_class.name.underscore.to_sym, merge_request: merge_request2) }
it 'returns the expected issuable' do
expect(event1.issuable).to eq(merge_request2)
end
end
end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment