From ad3e180ed3d99494414cb1b367f6b4e40ec28b87 Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Mon, 29 May 2017 13:49:17 +0800 Subject: 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 --- app/finders/events_finder.rb | 62 ++++ app/models/event.rb | 32 ++ ...-activity-feed-to-be-accessible-through-api.yml | 4 + doc/api/README.md | 1 + doc/api/events.md | 347 +++++++++++++++++++++ doc/api/projects.md | 138 +------- doc/api/users.md | 141 +-------- lib/api/api.rb | 1 + lib/api/events.rb | 86 +++++ lib/api/projects.rb | 10 - lib/api/users.rb | 21 -- spec/finders/events_finder_spec.rb | 44 +++ spec/requests/api/events_spec.rb | 133 ++++++++ spec/requests/api/projects_spec.rb | 58 ---- spec/requests/api/users_spec.rb | 77 ----- 15 files changed, 712 insertions(+), 443 deletions(-) create mode 100644 app/finders/events_finder.rb create mode 100644 changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml create mode 100644 doc/api/events.md create mode 100644 lib/api/events.rb create mode 100644 spec/finders/events_finder_spec.rb create mode 100644 spec/requests/api/events_spec.rb diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb new file mode 100644 index 00000000000..b0450ddc1fd --- /dev/null +++ b/app/finders/events_finder.rb @@ -0,0 +1,62 @@ +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 diff --git a/app/models/event.rb b/app/models/event.rb index 46e89388bc1..d6d39473774 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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) diff --git a/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml new file mode 100644 index 00000000000..9c17c3b949c --- /dev/null +++ b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml @@ -0,0 +1,4 @@ +--- +title: Introduce an Events API +merge_request: 11755 +author: diff --git a/doc/api/README.md b/doc/api/README.md index 44e345b1cf6..e1d4009dedc 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -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) diff --git a/doc/api/events.md b/doc/api/events.md new file mode 100644 index 00000000000..ef57287264d --- /dev/null +++ b/doc/api/events.md @@ -0,0 +1,347 @@ +# Events + +## Filter parameters + +### Action Types + +Available action types for the `action` parameter are: + +- `created` +- `updated` +- `closed` +- `reopened` +- `pushed` +- `commented` +- `merged` +- `joined` +- `left` +- `destroyed` +- `expired` + +Note that these options are downcased. + +### Target Types + +Available target types for the `target_type` parameter are: + +- `issue` +- `milestone` +- `merge_request` +- `note` +- `project` +- `snippet` +- `user` + +Note that these options are downcased. + +### Date formatting + +Dates for the `before` and `after` parameters should be supplied in the following format: + +``` +YYYY-MM-DD +``` + +## List currently authenticated user's events + +>**Note:** This endpoint was introduced in GitLab 9.3. + +Get a list of events for the authenticated user. + +``` +GET /events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +### Get user contribution events + +>**Note:** Documentation was formerly located in the [Users API pages][users-api]. + +Get the contribution events for the specified user, sorted from newest to oldest. + +``` +GET /users/:id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +```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" + } +] +``` + +## List a Project's visible events + +>**Note:** This endpoint has been around longer than the others. Documentation was formerly located in the [Projects API pages][projects-api]. + +Get a list of visible events for a particular project. + +``` +GET /:project_id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:project_id/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +[target-types]: #target-types "Target Type parameter" +[action-types]: #action-types "Action Type parameter" +[date-formatting]: #date-formatting "Date Formatting guidance" +[projects-api]: projects.md "Projects API pages" +[users-api]: users.md "Users API pages" diff --git a/doc/api/projects.md b/doc/api/projects.md index 70cad8a6025..0debdcfae89 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -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 diff --git a/doc/api/users.md b/doc/api/users.md index 7e118dcf4a9..f4167ba2605 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -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 diff --git a/lib/api/api.rb b/lib/api/api.rb index 7ae2f3cad40..88f91c07194 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -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 diff --git a/lib/api/events.rb b/lib/api/events.rb new file mode 100644 index 00000000000..ed5df268ae3 --- /dev/null +++ b/lib/api/events.rb @@ -0,0 +1,86 @@ +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 diff --git a/lib/api/projects.rb b/lib/api/projects.rb index deac3934d57..56046742e08 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -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 diff --git a/lib/api/users.rb b/lib/api/users.rb index e8694e90cf2..3f87a403a09 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -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 diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb new file mode 100644 index 00000000000..30a2bd14f10 --- /dev/null +++ b/spec/finders/events_finder_spec.rb @@ -0,0 +1,44 @@ +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 diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb new file mode 100644 index 00000000000..51e72c39a30 --- /dev/null +++ b/spec/requests/api/events_spec.rb @@ -0,0 +1,133 @@ +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) + end + end + + context 'when not permitted to read' do + it 'returns 404' do + get api("/projects/#{private_project.id}/events", non_member) + + expect(response).to have_http_status(404) + end + end + + context 'when authenticated' do + it 'returns project events' do + get api("/projects/#{private_project.id}/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 + + it 'returns 404 if project does not exist' do + get api("/projects/1234/events", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index c0ecb4d2aaa..86c57204971 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -762,64 +762,6 @@ describe API::Projects do end end - describe 'GET /projects/:id/events' do - shared_examples_for 'project events response' do - it 'returns the project events' do - member = create(:user) - create(:project_member, :developer, user: member, project: project) - note = create(:note_on_issue, note: 'What an awesome day!', project: project) - EventCreateService.new.leave_note(note, note.author) - - get api("/projects/#{project.id}/events", current_user) - - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - - first_event = json_response.first - expect(first_event['action_name']).to eq('commented on') - expect(first_event['note']['body']).to eq('What an awesome day!') - - last_event = json_response.last - - expect(last_event['action_name']).to eq('joined') - expect(last_event['project_id'].to_i).to eq(project.id) - expect(last_event['author_username']).to eq(member.username) - expect(last_event['author']['name']).to eq(member.name) - end - end - - context 'when unauthenticated' do - it_behaves_like 'project events response' do - let(:project) { create(:empty_project, :public) } - let(:current_user) { nil } - end - end - - context 'when authenticated' do - context 'valid request' do - it_behaves_like 'project events response' do - let(:current_user) { user } - end - end - - it 'returns a 404 error if not found' do - get api('/projects/42/events', user) - - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Project Not Found') - end - - it 'returns a 404 error if user is not a member' do - other_user = create(:user) - - get api("/projects/#{project.id}/events", other_user) - - expect(response).to have_http_status(404) - end - end - end - describe 'GET /projects/:id/users' do shared_examples_for 'project users response' do it 'returns the project users' do diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 1c33b8f9502..4efc3e1a1e2 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1130,83 +1130,6 @@ describe API::Users do end end - describe 'GET /users/:id/events' do - let(:user) { create(:user) } - let(:project) { create(:empty_project) } - let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) } - - before do - project.add_user(user, :developer) - EventCreateService.new.leave_note(note, user) - end - - context "as a user than cannot see the event's project" do - it 'returns no events' do - other_user = create(:user) - - 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 than can see the event's project" do - context 'joined event' do - it 'returns the "joined" event' 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 - - comment_event = json_response.find { |e| e['action_name'] == 'commented on' } - - expect(comment_event['project_id'].to_i).to eq(project.id) - expect(comment_event['author_username']).to eq(user.username) - expect(comment_event['note']['id']).to eq(note.id) - expect(comment_event['note']['body']).to eq('What an awesome day!') - - joined_event = json_response.find { |e| e['action_name'] == 'joined' } - - expect(joined_event['project_id'].to_i).to eq(project.id) - expect(joined_event['author_username']).to eq(user.username) - expect(joined_event['author']['name']).to eq(user.name) - end - end - - context 'when there are multiple events from different projects' do - let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } - let(:third_note) { create(:note_on_issue, project: project) } - - before do - second_note.project.add_user(user, :developer) - - [second_note, third_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' } - - expect(comment_events[0]['target_id']).to eq(third_note.id) - expect(comment_events[1]['target_id']).to eq(second_note.id) - expect(comment_events[2]['target_id']).to eq(note.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 - context "user activities", :redis do let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) } let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) } -- cgit v1.2.1 From 3c15f0eae757817b4d852be6b62abd3d73186d35 Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Fri, 2 Jun 2017 11:41:47 +0800 Subject: Accept a username for User-level Events API --- doc/api/events.md | 2 +- lib/api/events.rb | 4 ++-- spec/requests/api/events_spec.rb | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/doc/api/events.md b/doc/api/events.md index ef57287264d..e7829c9f479 100644 --- a/doc/api/events.md +++ b/doc/api/events.md @@ -129,7 +129,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the user | +| `id` | integer | yes | The ID or Username of the user | | `action` | string | no | Include only events of a particular [action type][action-types] | | `target_type` | string | no | Include only events of a particular [target type][target-types] | | `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | diff --git a/lib/api/events.rb b/lib/api/events.rb index ed5df268ae3..dabdf579119 100644 --- a/lib/api/events.rb +++ b/lib/api/events.rb @@ -42,7 +42,7 @@ module API end params do - requires :id, type: Integer, desc: 'The ID of the user' + requires :id, type: String, desc: 'The ID or Username of the user' end resource :users do desc 'Get the contribution events of a specified user' do @@ -55,7 +55,7 @@ module API use :sort_params end get ':id/events' do - user = User.find_by(id: params[:id]) + user = find_user(params[:id]) not_found!('User') unless user events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target) diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb index 51e72c39a30..a19870a95e8 100644 --- a/spec/requests/api/events_spec.rb +++ b/spec/requests/api/events_spec.rb @@ -41,6 +41,15 @@ describe API::Events, api: true do end context "as a user that can see the event's project" do + it 'accepts a username' do + get api("/users/#{user.username}/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 + it 'returns the events' do get api("/users/#{user.id}/events", user) -- cgit v1.2.1