Commit 31045c69 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'add-state-events-api-pd' into 'master'

Add state events to API

See merge request gitlab-org/gitlab!35210
parents 1ad2429e a2187cb2
# frozen_string_literal: true
class ResourceStateEventFinder
include FinderMethods
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)
end
private
attr_reader :current_user, :eventable
def read_ability
:"read_#{eventable.class.to_ability_name}"
end
end
......@@ -14,4 +14,8 @@ class ResourceStateEvent < ResourceEvent
def self.issuable_attrs
%i(issue merge_request).freeze
end
def issuable
issue || merge_request
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"
}
```
......@@ -179,6 +179,7 @@ module API
mount ::API::Discussions
mount ::API::ResourceLabelEvents
mount ::API::ResourceMilestoneEvents
mount ::API::ResourceStateEvents
mount ::API::NotificationSettings
mount ::API::Pages
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_class|
eventable_name = eventable_class.to_s.underscore
params do
requires :id, type: String, desc: "The ID of a project"
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc "Get a list of #{eventable_class.to_s.downcase} resource state events" do
success Entities::ResourceStateEvent
end
params do
requires :eventable_iid, types: Integer, desc: "The IID of the #{eventable_name}"
use :pagination
end
get ":id/#{eventable_name.pluralize}/:eventable_iid/resource_state_events" do
eventable = find_noteable(eventable_class, params[:eventable_iid])
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
success Entities::ResourceStateEvent
end
params do
requires :eventable_iid, types: Integer, desc: "The IID of the #{eventable_name}"
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" do
eventable = find_noteable(eventable_class, params[:eventable_iid])
event = ResourceStateEventFinder.new(current_user, eventable).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) }
describe '#execute' do
subject { described_class.new(user, issue).execute }
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let!(:event) { create(:resource_state_event, issue: issue) }
it 'returns events accessible by user' do
project.add_guest(user)
expect(subject).to eq [event]
end
context 'when issues are private' do
let(:project) { create(:project, :public, :issues_private) }
it 'does not return any events' do
expect(subject).to be_empty
end
end
context 'when issue is not accesible to the user' do
let(:project) { create(:project, :private) }
it 'does not return any events' do
expect(subject).to be_empty
end
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 an 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_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
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
......@@ -101,6 +101,16 @@ RSpec.shared_examples 'a resource event for issues' do
expect(events).to be_empty
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
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
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
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