Commit 12050668 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'time-tracking-api-ee' into 'master'

Time tracking api EE

See merge request !1073
parents e398b643 847f621d
...@@ -439,6 +439,7 @@ $help-shortcut-header-color: #333; ...@@ -439,6 +439,7 @@ $help-shortcut-header-color: #333;
*/ */
$issues-today-bg: #f3fff2; $issues-today-bg: #f3fff2;
$issues-today-border: #e1e8d5; $issues-today-border: #e1e8d5;
$compare-display-color: #888;
/* /*
* jQuery UI * jQuery UI
......
...@@ -539,7 +539,7 @@ ...@@ -539,7 +539,7 @@
.compare-display { .compare-display {
font-size: 13px; font-size: 13px;
color: $gl-text-color-secondary; color: $compare-display-color;
.compare-value { .compare-value {
color: $gl-text-color; color: $gl-text-color;
......
...@@ -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
...@@ -41,7 +41,7 @@ class IssuableBaseService < BaseService ...@@ -41,7 +41,7 @@ class IssuableBaseService < BaseService
end end
def create_time_spent_note(issuable) def create_time_spent_note(issuable)
SystemNoteService.change_time_spent(issuable, issuable.project, current_user) SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user)
end end
def filter_params(issuable) def filter_params(issuable)
...@@ -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
...@@ -206,7 +205,6 @@ class IssuableBaseService < BaseService ...@@ -206,7 +205,6 @@ class IssuableBaseService < BaseService
change_state(issuable) change_state(issuable)
change_subscription(issuable) change_subscription(issuable)
change_todo(issuable) change_todo(issuable)
time_spent = change_time_spent(issuable)
filter_params(issuable) filter_params(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
...@@ -214,7 +212,7 @@ class IssuableBaseService < BaseService ...@@ -214,7 +212,7 @@ class IssuableBaseService < BaseService
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:
...@@ -724,6 +724,146 @@ Example response: ...@@ -724,6 +724,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.
...@@ -1138,3 +1138,143 @@ Example response: ...@@ -1138,3 +1138,143 @@ 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
}
```
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
> Introduced in GitLab 8.14 in beta. > Introduced in GitLab 8.14 in beta.
Time Tracking allows you to track estimates and time spent on issues and merge Time Tracking allows you to track estimates and time spent on issues and merge
requests within GitLab. This functionality is in beta and is available for free requests within GitLab.
to all Enterprise Edition customers. As we expand on this feature, we will remove
it from Beta and a new License key will need to be issued to use it.
## Overview ## Overview
......
...@@ -293,6 +293,13 @@ module API ...@@ -293,6 +293,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
......
...@@ -91,6 +91,8 @@ module API ...@@ -91,6 +91,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?
...@@ -97,7 +99,7 @@ module API ...@@ -97,7 +99,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
...@@ -117,7 +119,7 @@ module API ...@@ -117,7 +119,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
...@@ -126,7 +128,7 @@ module API ...@@ -126,7 +128,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
...@@ -135,7 +137,7 @@ module API ...@@ -135,7 +137,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
...@@ -154,7 +156,7 @@ module API ...@@ -154,7 +156,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)
...@@ -181,7 +183,7 @@ module API ...@@ -181,7 +183,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
...@@ -217,7 +219,7 @@ module API ...@@ -217,7 +219,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)
...@@ -234,7 +236,7 @@ module API ...@@ -234,7 +236,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
...@@ -249,7 +251,7 @@ module API ...@@ -249,7 +251,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 = {
...@@ -274,7 +276,7 @@ module API ...@@ -274,7 +276,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'
end
post ":id/#{issuable_collection_name}/:#{issuable_key}/time_estimate" do
authorize! update_issuable_key, load_issuable
status :ok
update_issuable(time_estimate: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration)))
end
desc "Reset the time estimate for a project #{issuable_name}"
params do
requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
end
post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_time_estimate" do
authorize! update_issuable_key, load_issuable
status :ok
update_issuable(time_estimate: 0)
end
desc "Add spent time 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'
end
post ":id/#{issuable_collection_name}/:#{issuable_key}/add_spent_time" do
authorize! update_issuable_key, load_issuable
update_issuable(spend_time: {
duration: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration)),
user: current_user
})
end
desc "Reset spent time for a project #{issuable_name}"
params do
requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
end
post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_spent_time" do
authorize! update_issuable_key, load_issuable
status :ok
update_issuable(spend_time: { duration: :reset, user: current_user })
end
desc "Show time stats for a project #{issuable_name}"
params do
requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
end
get ":id/#{issuable_collection_name}/:#{issuable_key}/time_stats" do
authorize! read_issuable_key, load_issuable
present load_issuable, with: Entities::IssuableTimeStats
end
end
end
end
...@@ -4,7 +4,11 @@ module Gitlab ...@@ -4,7 +4,11 @@ module Gitlab
def parse(string) def parse(string)
with_custom_config do with_custom_config do
ChronicDuration.parse(string, default_unit: 'hours') rescue nil string.sub!(/\A-/, '')
seconds = ChronicDuration.parse(string, default_unit: 'hours') rescue nil
seconds *= -1 if seconds && Regexp.last_match
seconds
end end
end end
......
...@@ -431,7 +431,7 @@ describe Issue, "Issuable" do ...@@ -431,7 +431,7 @@ describe Issue, "Issuable" do
let(:issue) { create(:issue) } let(:issue) { create(:issue) }
def spend_time(seconds) def spend_time(seconds)
issue.spend_time(seconds, user) issue.spend_time(duration: seconds, user: user)
issue.save! issue.save!
end end
...@@ -455,10 +455,10 @@ describe Issue, "Issuable" do ...@@ -455,10 +455,10 @@ describe Issue, "Issuable" do
end end
context 'when time to substract exceeds the total time spent' do context 'when time to substract exceeds the total time spent' do
it 'should not alter the total time spent' do it 'raise a validation error' do
expect do
spend_time(-3600) spend_time(-3600)
end.to raise_error(ActiveRecord::RecordInvalid)
expect(issue.total_time_spent).to eq(1800)
end end
end end
end end
......
...@@ -1227,4 +1227,10 @@ describe API::Issues, api: true do ...@@ -1227,4 +1227,10 @@ describe API::Issues, api: true do
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
end end
describe 'time tracking endpoints' do
let(:issuable) { issue }
include_examples 'time tracking endpoints', 'issue'
end
end end
...@@ -6,7 +6,7 @@ describe API::MergeRequests, api: true do ...@@ -6,7 +6,7 @@ describe API::MergeRequests, api: true do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let(:non_member) { create(:user) } let(:non_member) { create(:user) }
let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) }
let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) } let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) }
let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) } let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') } let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
...@@ -809,6 +809,12 @@ describe API::MergeRequests, api: true do ...@@ -809,6 +809,12 @@ describe API::MergeRequests, api: true do
end end
end end
describe 'Time tracking' do
let(:issuable) { merge_request }
include_examples 'time tracking endpoints', 'merge_request'
end
def mr_with_later_created_and_updated_at_time def mr_with_later_created_and_updated_at_time
merge_request merge_request
merge_request.created_at += 1.hour merge_request.created_at += 1.hour
......
...@@ -222,7 +222,7 @@ describe SlashCommands::InterpretService, services: true do ...@@ -222,7 +222,7 @@ describe SlashCommands::InterpretService, services: true do
it 'populates spend_time: 3600 if content contains /spend 1h' do it 'populates spend_time: 3600 if content contains /spend 1h' do
_, updates = service.execute(content, issuable) _, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: 3600) expect(updates).to eq(spend_time: { duration: 3600, user: developer })
end end
end end
...@@ -230,7 +230,7 @@ describe SlashCommands::InterpretService, services: true do ...@@ -230,7 +230,7 @@ describe SlashCommands::InterpretService, services: true do
it 'populates spend_time: -1800 if content contains /spend -30m' do it 'populates spend_time: -1800 if content contains /spend -30m' do
_, updates = service.execute(content, issuable) _, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: -1800) expect(updates).to eq(spend_time: { duration: -1800, user: developer })
end end
end end
...@@ -246,7 +246,7 @@ describe SlashCommands::InterpretService, services: true do ...@@ -246,7 +246,7 @@ describe SlashCommands::InterpretService, services: true do
it 'populates spend_time: :reset if content contains /remove_time_spent' do it 'populates spend_time: :reset if content contains /remove_time_spent' do
_, updates = service.execute(content, issuable) _, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: :reset) expect(updates).to eq(spend_time: { duration: :reset, user: developer })
end end
end end
......
...@@ -757,7 +757,7 @@ describe SystemNoteService, services: true do ...@@ -757,7 +757,7 @@ describe SystemNoteService, services: true do
# We need a custom noteable in order to the shared examples to be green. # We need a custom noteable in order to the shared examples to be green.
let(:noteable) do let(:noteable) do
mr = create(:merge_request, source_project: project) mr = create(:merge_request, source_project: project)
mr.spend_time(1, author) mr.spend_time(duration: 360000, user: author)
mr.save! mr.save!
mr mr
end end
...@@ -793,7 +793,7 @@ describe SystemNoteService, services: true do ...@@ -793,7 +793,7 @@ describe SystemNoteService, services: true do
end end
def spend_time!(seconds) def spend_time!(seconds)
noteable.spend_time(seconds, author) noteable.spend_time(duration: seconds, user: author)
noteable.save! noteable.save!
end end
end end
......
shared_examples 'an unauthorized API user' do
it { is_expected.to eq(403) }
end
shared_examples 'time tracking endpoints' do |issuable_name|
issuable_collection_name = issuable_name.pluralize
describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do
context 'with an unauthorized user' do
subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') }
it_behaves_like 'an unauthorized API user'
end
it "sets the time estimate for #{issuable_name}" do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w'
expect(response).to have_http_status(200)
expect(json_response['human_time_estimate']).to eq('1w')
end
describe 'updating the current estimate' do
before do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w'
end
context 'when duration has a bad format' do
it 'does not modify the original estimate' do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo'
expect(response).to have_http_status(400)
expect(issuable.reload.human_time_estimate).to eq('1w')
end
end
context 'with a valid duration' do
it 'updates the estimate' do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h'
expect(response).to have_http_status(200)
expect(issuable.reload.human_time_estimate).to eq('3w 1h')
end
end
end
end
describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do
context 'with an unauthorized user' do
subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) }
it_behaves_like 'an unauthorized API user'
end
it "resets the time estimate for #{issuable_name}" do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user)
expect(response).to have_http_status(200)
expect(json_response['time_estimate']).to eq(0)
end
end
describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do
context 'with an unauthorized user' do
subject do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member),
duration: '2h'
end
it_behaves_like 'an unauthorized API user'
end
it "add spent time for #{issuable_name}" do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
duration: '2h'
expect(response).to have_http_status(201)
expect(json_response['human_total_time_spent']).to eq('2h')
end
context 'when subtracting time' do
it 'subtracts time of the total spent time' do
issuable.update_attributes!(spend_time: { duration: 7200, user: user })
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
duration: '-1h'
expect(response).to have_http_status(201)
expect(json_response['total_time_spent']).to eq(3600)
end
end
context 'when time to subtract is greater than the total spent time' do
it 'does not modify the total time spent' do
issuable.update_attributes!(spend_time: { duration: 7200, user: user })
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
duration: '-1w'
expect(response).to have_http_status(400)
expect(json_response['message']['time_spent'].first).to match(/exceeds the total time spent/)
end
end
end
describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do
context 'with an unauthorized user' do
subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) }
it_behaves_like 'an unauthorized API user'
end
it "resets spent time for #{issuable_name}" do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user)
expect(response).to have_http_status(200)
expect(json_response['total_time_spent']).to eq(0)
end
end
describe "GET /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_stats" do
it "returns the time stats for #{issuable_name}" do
issuable.update_attributes!(spend_time: { duration: 1800, user: user },
time_estimate: 3600)
get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user)
expect(response).to have_http_status(200)
expect(json_response['total_time_spent']).to eq(1800)
expect(json_response['time_estimate']).to eq(3600)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment