Commit ad3e180e authored by Mark Fletcher's avatar Mark Fletcher

Introduce an Events API

* Meld the following disparate endpoints:
 * `/projects/:id/events`
 * `/events`
 * `/users/:id/events`
+ Add result filtering to the above endpoints:
 * action
 * target_type
 * before and after dates
parent e34e5761
class EventsFinder
attr_reader :source, :params, :current_user
# Used to filter Events
#
# Arguments:
# source - which user or project to looks for events on
# current_user - only return events for projects visible to this user
# params:
# action: string
# target_type: string
# before: datetime
# after: datetime
#
def initialize(params = {})
@source = params.delete(:source)
@current_user = params.delete(:current_user)
@params = params
end
def execute
events = source.events
events = by_current_user_access(events)
events = by_action(events)
events = by_target_type(events)
events = by_created_at_before(events)
events = by_created_at_after(events)
events
end
private
def by_current_user_access(events)
events.merge(ProjectsFinder.new(current_user: current_user).execute).references(:project)
end
def by_action(events)
return events unless Event::ACTIONS[params[:action]]
events.where(action: Event::ACTIONS[params[:action]])
end
def by_target_type(events)
return events unless Event::TARGET_TYPES[params[:target_type]]
events.where(target_type: Event::TARGET_TYPES[params[:target_type]])
end
def by_created_at_before(events)
return events unless params[:before]
events.where('events.created_at < ?', params[:before].beginning_of_day)
end
def by_created_at_after(events)
return events unless params[:after]
events.where('events.created_at > ?', params[:after].end_of_day)
end
end
......@@ -14,6 +14,30 @@ class Event < ActiveRecord::Base
DESTROYED = 10
EXPIRED = 11 # User left project due to expiry
ACTIONS = HashWithIndifferentAccess.new(
created: CREATED,
updated: UPDATED,
closed: CLOSED,
reopened: REOPENED,
pushed: PUSHED,
commented: COMMENTED,
merged: MERGED,
joined: JOINED,
left: LEFT,
destroyed: DESTROYED,
expired: EXPIRED
).freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
milestone: Milestone,
merge_request: MergeRequest,
note: Note,
project: Project,
snippet: Snippet,
user: User
).freeze
RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour
delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true
......@@ -55,6 +79,14 @@ class Event < ActiveRecord::Base
def limit_recent(limit = 20, offset = nil)
recent.limit(limit).offset(offset)
end
def actions
ACTIONS.keys
end
def target_types
TARGET_TYPES.keys
end
end
def visible_to_user?(user = nil)
......
---
title: Introduce an Events API
merge_request: 11755
author:
......@@ -16,6 +16,7 @@ following locations:
- [Deployments](deployments.md)
- [Deploy Keys](deploy_keys.md)
- [Environments](environments.md)
- [Events](events.md)
- [Gitignores templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
......
This diff is collapsed.
......@@ -310,143 +310,7 @@ GET /projects/:id/users
### Get project events
Get the events for the specified project sorted from newest to oldest. This
endpoint can be accessed without authentication if the project is publicly
accessible.
```
GET /projects/:id/events
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
```json
[
{
"title": null,
"project_id": 15,
"action_name": "closed",
"target_id": 830,
"target_type": "Issue",
"author_id": 1,
"data": null,
"target_title": "Public project search field",
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
},
{
"title": null,
"project_id": 15,
"action_name": "opened",
"target_id": null,
"target_type": null,
"author_id": 1,
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "john",
"data": {
"before": "50d4420237a9de7be1304607147aec22e4a14af7",
"after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"ref": "refs/heads/master",
"user_id": 1,
"user_name": "Dmitriy Zaporozhets",
"repository": {
"name": "gitlabhq",
"url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
"description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
"homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
},
"commits": [
{
"id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"message": "Add simple search to projects in public area",
"timestamp": "2013-05-13T18:18:08+00:00",
"url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"author": {
"name": "Dmitriy Zaporozhets",
"email": "dmitriy.zaporozhets@gmail.com"
}
}
],
"total_commits_count": 1
},
"target_title": null
},
{
"title": null,
"project_id": 15,
"action_name": "closed",
"target_id": 840,
"target_type": "Issue",
"author_id": 1,
"data": null,
"target_title": "Finish & merge Code search PR",
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
},
{
"title": null,
"project_id": 15,
"action_name": "commented on",
"target_id": 1312,
"target_type": "Note",
"author_id": 1,
"data": null,
"target_title": null,
"created_at": "2015-12-04T10:33:58.089Z",
"note": {
"id": 1312,
"body": "What an awesome day!",
"attachment": null,
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"created_at": "2015-12-04T10:33:56.698Z",
"system": false,
"noteable_id": 377,
"noteable_type": "Issue"
},
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
}
]
```
Please refer to the [Events API documentation](events.md#list-a-projects-visible-events)
### Create project
......
......@@ -701,147 +701,8 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or
### Get user contribution events
Get the contribution events for the specified user, sorted from newest to oldest.
Please refer to the [Events API documentation](events.md#get-user-contribution-events)
```
GET /users/:id/events
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events
```
Example response:
```json
[
{
"title": null,
"project_id": 15,
"action_name": "closed",
"target_id": 830,
"target_type": "Issue",
"author_id": 1,
"data": null,
"target_title": "Public project search field",
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
},
{
"title": null,
"project_id": 15,
"action_name": "opened",
"target_id": null,
"target_type": null,
"author_id": 1,
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "john",
"data": {
"before": "50d4420237a9de7be1304607147aec22e4a14af7",
"after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"ref": "refs/heads/master",
"user_id": 1,
"user_name": "Dmitriy Zaporozhets",
"repository": {
"name": "gitlabhq",
"url": "git@dev.gitlab.org:gitlab/gitlabhq.git",
"description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.",
"homepage": "https://dev.gitlab.org/gitlab/gitlabhq"
},
"commits": [
{
"id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"message": "Add simple search to projects in public area",
"timestamp": "2013-05-13T18:18:08+00:00",
"url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428",
"author": {
"name": "Dmitriy Zaporozhets",
"email": "dmitriy.zaporozhets@gmail.com"
}
}
],
"total_commits_count": 1
},
"target_title": null
},
{
"title": null,
"project_id": 15,
"action_name": "closed",
"target_id": 840,
"target_type": "Issue",
"author_id": 1,
"data": null,
"target_title": "Finish & merge Code search PR",
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
},
{
"title": null,
"project_id": 15,
"action_name": "commented on",
"target_id": 1312,
"target_type": "Note",
"author_id": 1,
"data": null,
"target_title": null,
"created_at": "2015-12-04T10:33:58.089Z",
"note": {
"id": 1312,
"body": "What an awesome day!",
"attachment": null,
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"created_at": "2015-12-04T10:33:56.698Z",
"system": false,
"noteable_id": 377,
"noteable_type": "Issue"
},
"author": {
"name": "Dmitriy Zaporozhets",
"username": "root",
"id": 1,
"state": "active",
"avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png",
"web_url": "http://localhost:3000/root"
},
"author_username": "root"
}
]
```
## Get all impersonation tokens of a user
......
......@@ -94,6 +94,7 @@ module API
mount ::API::DeployKeys
mount ::API::Deployments
mount ::API::Environments
mount ::API::Events
mount ::API::Features
mount ::API::Files
mount ::API::Groups
......
module API
class Events < Grape::API
include PaginationParams
helpers do
params :event_filter_params do
optional :action, type: String, values: Event.actions, desc: 'Event action to filter on'
optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on'
optional :before, type: Date, desc: 'Include only events created before this date'
optional :after, type: Date, desc: 'Include only events created after this date'
end
params :sort_params do
optional :sort, type: String, values: %w[asc desc], default: 'desc',
desc: 'Return events sorted in ascending and descending order'
end
def present_events(events)
events = events.reorder(created_at: params[:sort])
present paginate(events), with: Entities::Event
end
end
resource :events do
desc "List currently authenticated user's events" do
detail 'This feature was introduced in GitLab 9.3.'
success Entities::Event
end
params do
use :pagination
use :event_filter_params
use :sort_params
end
get do
authenticate!
events = EventsFinder.new(params.merge(source: current_user, current_user: current_user)).execute.preload(:author, :target)
present_events(events)
end
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
end
resource :users do
desc 'Get the contribution events of a specified user' do
detail 'This feature was introduced in GitLab 8.13.'
success Entities::Event
end
params do
use :pagination
use :event_filter_params
use :sort_params
end
get ':id/events' do
user = User.find_by(id: params[:id])
not_found!('User') unless user
events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target)
present_events(events)
end
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: { id: %r{[^/]+} } do
desc "List a Project's visible events" do
success Entities::Event
end
params do
use :pagination
use :event_filter_params
use :sort_params
end
get ":id/events" do
events = EventsFinder.new(params.merge(source: user_project, current_user: current_user)).execute.preload(:author, :target)
present_events(events)
end
end
end
end
......@@ -167,16 +167,6 @@ module API
user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics]
end
desc 'Get events for a single project' do
success Entities::Event
end
params do
use :pagination
end
get ":id/events" do
present paginate(user_project.events.recent), with: Entities::Event
end
desc 'Fork new project for the current user or provided namespace.' do
success Entities::Project
end
......
......@@ -328,27 +328,6 @@ module API
end
end
desc 'Get the contribution events of a specified user' do
detail 'This feature was introduced in GitLab 8.13.'
success Entities::Event
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
use :pagination
end
get ':id/events' do
user = User.find_by(id: params[:id])
not_found!('User') unless user
events = user.events.
merge(ProjectsFinder.new(current_user: current_user).execute).
references(:project).
with_associations.
recent
present paginate(events), with: Entities::Event
end
params do
requires :user_id, type: Integer, desc: 'The ID of the user'
end
......
require 'spec_helper'
describe EventsFinder do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:project1) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
let(:project2) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
let(:closed_issue) { create(:closed_issue, project: project1, author: user) }
let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) }
let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
let!(:opened_merge_request_event) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 1, 31)) }
let(:closed_issue2) { create(:closed_issue, project: project1, author: user) }
let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) }
let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) }
let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) }
context 'when targeting a user' do
it 'returns events between specified dates filtered on action and type' do
events = described_class.new(source: user, current_user: user, action: 'created', target_type: 'merge_request', after: Date.new(2017, 1, 1), before: Date.new(2017, 2, 1)).execute
expect(events).to eq([opened_merge_request_event])
end
it 'does not return events the current_user does not have access to' do
events = described_class.new(source: user, current_user: other_user).execute
expect(events).not_to include(opened_merge_request_event)
end
end
context 'when targeting a project' do
it 'returns project events between specified dates filtered on action and type' do
events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute
expect(events).to eq([closed_issue_event])
end
it 'does not return events the current_user does not have access to' do
events = described_class.new(source: project2, current_user: other_user).execute
expect(events).to be_empty
end
end
end
require 'spec_helper'
describe API::Events, api: true do
include ApiHelpers
let(:user) { create(:user) }
let(:non_member) { create(:user) }
let(:other_user) { create(:user, username: 'otheruser') }
let(:private_project) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) }
let(:closed_issue) { create(:closed_issue, project: private_project, author: user) }
let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) }
describe 'GET /events' do
context 'when unauthenticated' do
it 'returns authentication error' do
get api('/events')
expect(response).to have_http_status(401)
end
end
context 'when authenticated' do
it 'returns users events' do
get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31', user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
end
end
end
describe 'GET /users/:id/events' do
context "as a user that cannot see the event's project" do
it 'returns no events' do
get api("/users/#{user.id}/events", other_user)
expect(response).to have_http_status(200)
expect(json_response).to be_empty
end
end
context "as a user that can see the event's project" do
it 'returns the events' do
get api("/users/#{user.id}/events", user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
end
context 'when there are multiple events from different projects' do
let(:second_note) { create(:note_on_issue, project: create(:empty_project)) }
before do
second_note.project.add_user(user, :developer)
[second_note].each do |note|
EventCreateService.new.leave_note(note, user)
end
end
it 'returns events in the correct order (from newest to oldest)' do
get api("/users/#{user.id}/events", user)
comment_events = json_response.select { |e| e['action_name'] == 'commented on' }
close_events = json_response.select { |e| e['action_name'] == 'closed' }
expect(comment_events[0]['target_id']).to eq(second_note.id)
expect(close_events[0]['target_id']).to eq(closed_issue.id)
end
it 'accepts filter parameters' do
get api("/users/#{user.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user)
expect(json_response.size).to eq(1)
expect(json_response[0]['target_id']).to eq(closed_issue.id)
end
end
end
it 'returns a 404 error if not found' do
get api('/users/42/events', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
end
describe 'GET /projects/:id/events' do
context 'when unauthenticated ' do
it 'returns 404 for private project' do
get api("/projects/#{private_project.id}/events")
expect(response).to have_http_status(404)
end
it 'returns 200 status for a public project' do
public_project = create(:empty_project, :public)
get api("/projects/#{public_project.id}/events")
expect(response).to have_http_status(200)