Commit a54aee49 authored by Patrick Derichs's avatar Patrick Derichs

Add API endpoint for resource milestone events

Also add specs and documentation

Edit intro to docs page

Edit docs examples

- Rearrange example requests and responses
- Surround request URLs in quotation marks

Apply suggestion to app/finders/resource_milestone_event_finder.rb

Apply suggestion to doc/api/resource_milestone_events.md
parent 0060d875
# frozen_string_literal: true
class ResourceMilestoneEventFinder
include FinderMethods
MAX_PER_PAGE = 100
attr_reader :params, :current_user, :eventable
def initialize(current_user, eventable, params = {})
@current_user = current_user
@eventable = eventable
@params = params
end
def execute
Kaminari.paginate_array(visible_events)
end
private
def visible_events
@visible_events ||= visible_to_user(events)
end
def events
@events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page)
end
def visible_to_user(events)
events.select { |event| visible_for_user?(event) }
end
def visible_for_user?(event)
milestone = event_milestones[event.milestone_id]
return if milestone.blank?
parent = milestone.parent
parent_availabilities[key_for_parent(parent)]
end
def parent_availabilities
@parent_availabilities ||= relevant_parents.to_h do |parent|
[key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)]
end
end
def key_for_parent(parent)
"#{parent.class.name}_#{parent.id}"
end
def event_milestones
@milestones ||= events.map(&:milestone).uniq.to_h do |milestone|
[milestone.id, milestone]
end
end
def relevant_parents
@relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent }
end
def per_page
[params[:per_page], MAX_PER_PAGE].compact.min
end
def page
params[:page] || 1
end
end
......@@ -9,6 +9,8 @@ class ResourceMilestoneEvent < ResourceEvent
validate :exactly_one_issuable
scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
enum action: {
add: 1,
remove: 2
......@@ -26,4 +28,12 @@ class ResourceMilestoneEvent < ResourceEvent
def milestone_title
milestone&.title
end
def milestone_parent
milestone&.parent
end
def issuable
issue || merge_request
end
end
---
title: Add API endpoint for resource milestone events
merge_request: 31720
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 milestone events API
Resource milestone events keep track of what happens to GitLab [issues](../user/project/issues/),
[merge requests](../user/project/merge_requests/), and [epics](../user/group/epics/).
Use them to track which milestone was added or removed, who did it, and when it happened.
## Issues
### List project issue milestone events
Gets a list of all milestone events for a single issue.
```plaintext
GET /projects/:id/issues/:issue_iid/resource_milestone_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_milestone_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": 253,
"milestone": {
"id": 61,
"iid": 9,
"project_id": 7,
"title": "v1.2",
"description": "Ipsum Lorem",
"state": "active",
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": null,
"start_date": null,
"web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9"
},
"action": "add"
},
{
"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": 253,
"milestone": {
"id": 61,
"iid": 9,
"project_id": 7,
"title": "v1.2",
"description": "Ipsum Lorem",
"state": "active",
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": null,
"start_date": null,
"web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9"
},
"action": "remove"
}
]
```
### Get single issue milestone event
Returns a single milestone event for a specific project issue
```plaintext
GET /projects/:id/issues/:issue_iid/resource_milestone_events/:resource_milestone_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_milestone_event_id` | integer | yes | The ID of a milestone event |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/resource_milestone_events/1"
```
## Merge requests
### List project merge request milestone events
Gets a list of all milestone events for a single merge request.
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/resource_milestone_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_milestone_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": 142,
"milestone": {
"id": 61,
"iid": 9,
"project_id": 7,
"title": "v1.2",
"description": "Ipsum Lorem",
"state": "active",
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": null,
"start_date": null,
"web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9"
},
"action": "add"
},
{
"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": 142,
"milestone": {
"id": 61,
"iid": 9,
"project_id": 7,
"title": "v1.2",
"description": "Ipsum Lorem",
"state": "active",
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": null,
"start_date": null,
"web_url": "http://gitlab.example.com:3000/group/project/-/milestones/9"
},
"action": "remove"
}
]
```
### Get single merge request milestone event
Returns a single milestone event for a specific project merge request
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/resource_milestone_events/:resource_milestone_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_milestone_event_id` | integer | yes | The ID of a milestone 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_milestone_events/120"
```
......@@ -170,6 +170,7 @@ module API
mount ::API::Notes
mount ::API::Discussions
mount ::API::ResourceLabelEvents
mount ::API::ResourceMilestoneEvents
mount ::API::NotificationSettings
mount ::API::Pages
mount ::API::PagesDomains
......
# frozen_string_literal: true
module API
module Entities
class ResourceMilestoneEvent < 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 :milestone, using: Entities::Milestone
expose :action
expose :state
end
end
end
# frozen_string_literal: true
module API
class ResourceMilestoneEvents < Grape::API
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 milestone events" do
success Entities::ResourceMilestoneEvent
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_milestone_events" do
eventable = find_noteable(eventable_type, params[:eventable_id])
opts = { page: params[:page], per_page: params[:per_page] }
events = ResourceMilestoneEventFinder.new(current_user, eventable, opts).execute
present paginate(events), with: Entities::ResourceMilestoneEvent
end
desc "Get a single #{eventable_type.to_s.downcase} resource milestone event" do
success Entities::ResourceMilestoneEvent
end
params do
requires :event_id, type: String, desc: 'The ID of a resource milestone event'
requires :eventable_id, types: [Integer, String], desc: 'The ID of the eventable'
end
get ":id/#{eventables_str}/:eventable_id/resource_milestone_events/:event_id" do
eventable = find_noteable(eventable_type, params[:eventable_id])
event = eventable.resource_milestone_events.find(params[:event_id])
not_found!('ResourceMilestoneEvent') unless can?(current_user, :read_milestone, event.milestone_parent)
present event, with: Entities::ResourceMilestoneEvent
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ResourceMilestoneEventFinder 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 with milestones accessible by user' do
milestone = create(:milestone, project: issue_project)
event = create_event(milestone)
issue_project.add_guest(user)
expect(subject).to eq [event]
end
it 'filters events with public project milestones if issues and MRs are private' do
project = create(:project, :public, :issues_private, :merge_requests_private)
milestone = create(:milestone, project: project)
create_event(milestone)
expect(subject).to be_empty
end
it 'filters events with project milestones not accessible by user' do
project = create(:project, :private)
milestone = create(:milestone, project: project)
create_event(milestone)
expect(subject).to be_empty
end
it 'filters events with group milestones not accessible by user' do
group = create(:group, :private)
milestone = create(:milestone, group: group)
create_event(milestone)
expect(subject).to be_empty
end
it 'paginates results' do
milestone = create(:milestone, project: issue_project)
create_event(milestone)
create_event(milestone)
issue_project.add_guest(user)
paginated = described_class.new(user, issue, per_page: 1).execute
expect(subject.count).to eq 2
expect(paginated.count).to eq 1
end
context 'when multiple events share the same milestone' do
it 'avoids N+1 queries' do
issue_project.add_developer(user)
milestone1 = create(:milestone, project: issue_project)
milestone2 = create(:milestone, project: issue_project)
control_count = ActiveRecord::QueryRecorder.new { described_class.new(user, issue).execute }.count
expect(control_count).to eq(1) # 1 events query
create_event(milestone1, :add)
create_event(milestone1, :remove)
create_event(milestone1, :add)
create_event(milestone1, :remove)
create_event(milestone2, :add)
create_event(milestone2, :remove)
# 1 events + 1 milestones + 1 project + 1 user + 4 ability
expect { described_class.new(user, issue).execute }.not_to exceed_query_limit(control_count + 7)
end
end
def create_event(milestone, action = :add)
create(:resource_milestone_event, issue: issue, milestone: milestone, action: action)
end
end
end
......@@ -95,4 +95,34 @@ describe ResourceMilestoneEvent, type: :model do
end
end
end
describe '#milestone_parent' do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:milestone) { create(:milestone, project: project) }
let(:event) { create(:resource_milestone_event, milestone: milestone) }
context 'when milestone parent is project' do
it 'returns the expected parent' do
expect(event.milestone_parent).to eq(project)
end
end
context 'when milestone parent is group' do
let(:milestone) { create(:milestone, group: group) }
it 'returns the expected parent' do
expect(event.milestone_parent).to eq(group)
end
end
context 'when milestone is nil' do
let(:event) { create(:resource_milestone_event, milestone: nil) }
it 'returns nil' do
expect(event.milestone_parent).to be_nil
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::ResourceMilestoneEvents do
let!(:user) { create(:user) }
let!(:project) { create(:project, :public, namespace: user.namespace) }
let!(:milestone) { create(:milestone, project: project) }
before do
project.add_developer(user)
end
context 'when eventable is an Issue' do
it_behaves_like 'resource_milestone_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_milestone_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_milestone_events API' do |parent_type, eventable_type, id_name|
describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_milestone_events" do
let!(:event) { create_event(milestone) }
it "returns an array of resource milestone events" do
url = "/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_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['milestone']['id']).to eq(event.milestone.id)
expect(json_response.first['action']).to eq(event.action)
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_milestone_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_milestone_events", private_user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe "GET /#{parent_type}/:id/#{eventable_type}/:noteable_id/resource_milestone_events/:event_id" do
let!(:event) { create_event(milestone) }
it "returns a resource milestone event by id" do
get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events/#{event.id}", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(event.id)
expect(json_response['milestone']['id']).to eq(event.milestone.id)
expect(json_response['action']).to eq(event.action)
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_milestone_events/#{event.id}", private_user)
expect(response).to have_gitlab_http_status(:not_found)
end
it "returns a 404 error if resource milestone event not found" do
get api("/#{parent_type}/#{parent.id}/#{eventable_type}/#{eventable[id_name]}/resource_milestone_events/#{non_existing_record_id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
def create_event(milestone, action: :add)
create(:resource_milestone_event, eventable.class.name.underscore => eventable, milestone: milestone, action: action)
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