Commit 0f3c9355 authored by Ruben Davila's avatar Ruben Davila

Add some API endpoints for time tracking.

New endpoints are:

POST :project_id/(issues|merge_requests)/(:issue_id|:merge_request_id)/time_estimate"

POST :project_id/(issues|merge_requests)/(:issue_id|:merge_request_id)/reset_time_estimate"

POST :project_id/(issues|merge_requests)/(:issue_id|:merge_request_id)/add_spent_time"

POST :project_id/(issues|merge_requests)/(:issue_id|:merge_request_id)/reset_spent_time"

GET  :project_id/(issues|merge_requests)/(:issue_id|:merge_request_id)/time_stats"
parent 63b36241
...@@ -9,27 +9,32 @@ module TimeTrackable ...@@ -9,27 +9,32 @@ module TimeTrackable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
attr_reader :time_spent attr_reader :time_spent, :time_spent_user
alias_method :time_spent?, :time_spent alias_method :time_spent?, :time_spent
default_value_for :time_estimate, value: 0, allows_nil: false default_value_for :time_estimate, value: 0, allows_nil: false
validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
validate :check_negative_time_spent
has_many :timelogs, as: :trackable, dependent: :destroy has_many :timelogs, as: :trackable, dependent: :destroy
end end
def spend_time(seconds, user) def spend_time(options)
return if seconds == 0 @time_spent = options[:duration]
@time_spent_user = options[:user]
@original_total_time_spent = nil
@time_spent = seconds return if @time_spent == 0
@time_spent_user = user
if seconds == :reset if @time_spent == :reset
reset_spent_time reset_spent_time
else else
add_or_subtract_spent_time add_or_subtract_spent_time
end end
end end
alias_method :spend_time=, :spend_time
def total_time_spent def total_time_spent
timelogs.sum(:time_spent) timelogs.sum(:time_spent)
...@@ -50,9 +55,18 @@ module TimeTrackable ...@@ -50,9 +55,18 @@ module TimeTrackable
end end
def add_or_subtract_spent_time def add_or_subtract_spent_time
# Exit if time to subtract exceeds the total time spent.
return if time_spent < 0 && (time_spent.abs > total_time_spent)
timelogs.new(time_spent: time_spent, user: @time_spent_user) timelogs.new(time_spent: time_spent, user: @time_spent_user)
end end
def check_negative_time_spent
return if time_spent.nil? || time_spent == :reset
# we need to cache the total time spent so multiple calls to #valid?
# doesn't give a false error
@original_total_time_spent ||= total_time_spent
if time_spent < 0 && (time_spent.abs > @original_total_time_spent)
errors.add(:time_spent, 'Time to subtract exceeds the total time spent')
end
end
end end
...@@ -164,7 +164,6 @@ class IssuableBaseService < BaseService ...@@ -164,7 +164,6 @@ class IssuableBaseService < BaseService
def create(issuable) def create(issuable)
merge_slash_commands_into_params!(issuable) merge_slash_commands_into_params!(issuable)
filter_params(issuable) filter_params(issuable)
change_time_spent(issuable)
params.delete(:state_event) params.delete(:state_event)
params[:author] ||= current_user params[:author] ||= current_user
...@@ -207,14 +206,13 @@ class IssuableBaseService < BaseService ...@@ -207,14 +206,13 @@ class IssuableBaseService < BaseService
change_subscription(issuable) change_subscription(issuable)
change_todo(issuable) change_todo(issuable)
filter_params(issuable) filter_params(issuable)
time_spent = change_time_spent(issuable)
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a old_mentioned_users = issuable.mentioned_users.to_a
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
if (params.present? || time_spent) && update_issuable(issuable, params) if params.present? && update_issuable(issuable, params)
# We do not touch as it will affect a update on updated_at field # We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do ActiveRecord::Base.no_touching do
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
...@@ -261,12 +259,6 @@ class IssuableBaseService < BaseService ...@@ -261,12 +259,6 @@ class IssuableBaseService < BaseService
end end
end end
def change_time_spent(issuable)
time_spent = params.delete(:spend_time)
issuable.spend_time(time_spent, current_user) if time_spent
end
def has_changes?(issuable, old_labels: []) def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
......
...@@ -262,13 +262,10 @@ module SlashCommands ...@@ -262,13 +262,10 @@ module SlashCommands
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end end
command :spend do |raw_duration| command :spend do |raw_duration|
reduce_time = raw_duration.sub!(/\A-/, '')
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
if time_spent if time_spent
time_spent *= -1 if reduce_time @updates[:spend_time] = { duration: time_spent, user: current_user }
@updates[:spend_time] = time_spent
end end
end end
...@@ -287,7 +284,7 @@ module SlashCommands ...@@ -287,7 +284,7 @@ module SlashCommands
current_user.can?(:"admin_#{issuable.to_ability_name}", project) current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end end
command :remove_time_spent do command :remove_time_spent do
@updates[:spend_time] = :reset @updates[:spend_time] = { duration: :reset, user: current_user }
end end
# This is a dummy command, so that it appears in the autocomplete commands # This is a dummy command, so that it appears in the autocomplete commands
......
---
title: Add new endpoints for Time Tracking.
merge_request: 8483
author:
...@@ -712,6 +712,146 @@ Example response: ...@@ -712,6 +712,146 @@ Example response:
} }
``` ```
## Set a time estimate for an issue
Sets an estimated time of work for this issue.
```
POST /projects/:id/issues/:issue_id/time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_estimate?duration=3h30m
```
Example response:
```json
{
"human_time_estimate": "3h 30m",
"human_total_time_spent": null,
"time_estimate": 12600,
"total_time_spent": 0
}
```
## Reset the time estimate for an issue
Resets the estimated time for this issue to 0 seconds.
```
POST /projects/:id/issues/:issue_id/reset_time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_time_estimate
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Add spent time for an issue
Adds spent time for this issue
```
POST /projects/:id/issues/:issue_id/add_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/add_spent_time?duration=1h
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": "1h",
"time_estimate": 0,
"total_time_spent": 3600
}
```
## Reset spent time for an issue
Resets the total spent time for this issue to 0 seconds.
```
POST /projects/:id/issues/:issue_id/reset_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_spent_time
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Get time tracking stats
```
GET /projects/:id/issues/:issue_id/time_stats
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_stats
```
Example response:
```json
{
"human_time_estimate": "2h",
"human_total_time_spent": "1h",
"time_estimate": 7200,
"total_time_spent": 3600
}
```
## Comments on issues ## Comments on issues
Comments are done via the [notes](notes.md) resource. Comments are done via the [notes](notes.md) resource.
...@@ -1018,3 +1018,142 @@ Example response: ...@@ -1018,3 +1018,142 @@ Example response:
}] }]
} }
``` ```
## Set a time estimate for a merge request
Sets an estimated time of work for this merge request.
```
POST /projects/:id/merge_requests/:merge_request_id/time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a project's merge request |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/time_estimate?duration=3h30m
```
Example response:
```json
{
"human_time_estimate": "3h 30m",
"human_total_time_spent": null,
"time_estimate": 12600,
"total_time_spent": 0
}
```
## Reset the time estimate for a merge request
Resets the estimated time for this merge request to 0 seconds.
```
POST /projects/:id/merge_requests/:merge_request_id/reset_time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a project's merge_request |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/reset_time_estimate
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Add spent time for a merge request
Adds spent time for this merge request
```
POST /projects/:id/merge_requests/:merge_request_id/add_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a project's merge request |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/add_spent_time?duration=1h
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": "1h",
"time_estimate": 0,
"total_time_spent": 3600
}
```
## Reset spent time for a merge request
Resets the total spent time for this merge request to 0 seconds.
```
POST /projects/:id/merge_requests/:merge_request_id/reset_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a project's merge_request |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/reset_spent_time
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Get time tracking stats
```
GET /projects/:id/merge_requests/:merge_request_id/time_stats
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a project's merge request |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/time_stats
```
Example response:
```json
{
"human_time_estimate": "2h",
"human_total_time_spent": "1h",
"time_estimate": 7200,
"total_time_spent": 3600
}
```
...@@ -268,6 +268,13 @@ module API ...@@ -268,6 +268,13 @@ module API
end end
end end
class IssuableTimeStats < Grape::Entity
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end
class ExternalIssue < Grape::Entity class ExternalIssue < Grape::Entity
expose :title expose :title
expose :id expose :id
......
...@@ -86,6 +86,10 @@ module API ...@@ -86,6 +86,10 @@ module API
IssuesFinder.new(current_user, project_id: user_project.id).find(id) IssuesFinder.new(current_user, project_id: user_project.id).find(id)
end end
def find_project_merge_request(id)
MergeRequestsFinder.new(current_user, project_id: user_project.id).find(id)
end
def authenticate! def authenticate!
unauthorized! unless current_user unauthorized! unless current_user
end end
......
...@@ -89,6 +89,8 @@ module API ...@@ -89,6 +89,8 @@ module API
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
end end
resource :projects do resource :projects do
include TimeTrackingEndpoints
desc 'Get a list of project issues' do desc 'Get a list of project issues' do
success Entities::Issue success Entities::Issue
end end
......
...@@ -10,6 +10,8 @@ module API ...@@ -10,6 +10,8 @@ module API
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
end end
resource :projects do resource :projects do
include TimeTrackingEndpoints
helpers do helpers do
def handle_merge_request_errors!(errors) def handle_merge_request_errors!(errors)
if errors[:project_access].any? if errors[:project_access].any?
...@@ -96,7 +98,7 @@ module API ...@@ -96,7 +98,7 @@ module API
requires :merge_request_id, type: Integer, desc: 'The ID of a merge request' requires :merge_request_id, type: Integer, desc: 'The ID of a merge request'
end end
delete ":id/merge_requests/:merge_request_id" do delete ":id/merge_requests/:merge_request_id" do
merge_request = user_project.merge_requests.find_by(id: params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize!(:destroy_merge_request, merge_request) authorize!(:destroy_merge_request, merge_request)
merge_request.destroy merge_request.destroy
...@@ -116,7 +118,7 @@ module API ...@@ -116,7 +118,7 @@ module API
success Entities::MergeRequest success Entities::MergeRequest
end end
get path do get path do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request authorize! :read_merge_request, merge_request
present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end end
...@@ -125,7 +127,7 @@ module API ...@@ -125,7 +127,7 @@ module API
success Entities::RepoCommit success Entities::RepoCommit
end end
get "#{path}/commits" do get "#{path}/commits" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request authorize! :read_merge_request, merge_request
present merge_request.commits, with: Entities::RepoCommit present merge_request.commits, with: Entities::RepoCommit
end end
...@@ -134,7 +136,7 @@ module API ...@@ -134,7 +136,7 @@ module API
success Entities::MergeRequestChanges success Entities::MergeRequestChanges
end end
get "#{path}/changes" do get "#{path}/changes" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request authorize! :read_merge_request, merge_request
present merge_request, with: Entities::MergeRequestChanges, current_user: current_user present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
end end
...@@ -153,7 +155,7 @@ module API ...@@ -153,7 +155,7 @@ module API
:remove_source_branch :remove_source_branch
end end
put path do put path do
merge_request = user_project.merge_requests.find(params.delete(:merge_request_id)) merge_request = find_project_merge_request(params.delete(:merge_request_id))
authorize! :update_merge_request, merge_request authorize! :update_merge_request, merge_request
mr_params = declared_params(include_missing: false) mr_params = declared_params(include_missing: false)
...@@ -180,7 +182,7 @@ module API ...@@ -180,7 +182,7 @@ module API
optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
end end
put "#{path}/merge" do put "#{path}/merge" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
# Merge request can not be merged # Merge request can not be merged
# because user dont have permissions to push into target branch # because user dont have permissions to push into target branch
...@@ -216,7 +218,7 @@ module API ...@@ -216,7 +218,7 @@ module API
success Entities::MergeRequest success Entities::MergeRequest
end end
post "#{path}/cancel_merge_when_build_succeeds" do post "#{path}/cancel_merge_when_build_succeeds" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user) unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user)
...@@ -233,7 +235,7 @@ module API ...@@ -233,7 +235,7 @@ module API
use :pagination use :pagination
end end
get "#{path}/comments" do get "#{path}/comments" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request authorize! :read_merge_request, merge_request
...@@ -248,7 +250,7 @@ module API ...@@ -248,7 +250,7 @@ module API
requires :note, type: String, desc: 'The text of the comment' requires :note, type: String, desc: 'The text of the comment'
end end
post "#{path}/comments" do post "#{path}/comments" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :create_note, merge_request authorize! :create_note, merge_request
opts = { opts = {
...@@ -273,7 +275,7 @@ module API ...@@ -273,7 +275,7 @@ module API
use :pagination use :pagination
end end
get "#{path}/closes_issues" do get "#{path}/closes_issues" do
merge_request = user_project.merge_requests.find(params[:merge_request_id]) merge_request = find_project_merge_request(params[:merge_request_id])
issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user)) issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
present paginate(issues), with: issue_entity(user_project), current_user: current_user present paginate(issues), with: issue_entity(user_project), current_user: current_user
end end
......
module API
module TimeTrackingEndpoints
extend ActiveSupport::Concern
included do
helpers do
def issuable_name
declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request'
end
def issuable_key
"#{issuable_name}_id".to_sym
end
def update_issuable_key
"update_#{issuable_name}".to_sym
end
def read_issuable_key
"read_#{issuable_name}".to_sym
end
def load_issuable
@issuable ||= begin
case issuable_name
when 'issue'
find_project_issue(params.delete(issuable_key))
when 'merge_request'
find_project_merge_request(params.delete(issuable_key))
end
end
end
def update_issuable(attrs)
custom_params = declared_params(include_missing: false)
custom_params.merge!(attrs)
issuable = update_service.new(user_project, current_user, custom_params).execute(load_issuable)
if issuable.valid?
present issuable, with: Entities::IssuableTimeStats
else
render_validation_error!(issuable)
end
end
def update_service
issuable_name == 'issue' ? ::Issues::UpdateService : ::MergeRequests::UpdateService
end
end
issuable_name = name.end_with?('Issues') ? 'issue' : 'merge_request'
issuable_collection_name = issuable_name.pluralize
issuable_key = "#{issuable_name}_id".to_sym
desc "Set a time estimate for a project #{issuable_name}"
params do
requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
requires :duration, type: String, desc: 'The duration to be parsed'