Backport backend work for time tracking.
This commit is contained in:
parent
64dd41a0e2
commit
17196a2ff3
30 changed files with 693 additions and 18 deletions
2
app/assets/javascripts/lib/vue_resource.js.es6
Normal file
2
app/assets/javascripts/lib/vue_resource.js.es6
Normal file
|
@ -0,0 +1,2 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
|
@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3);
|
|||
$border-white-light: darken($white-light, $darken-border-factor);
|
||||
$border-white-normal: darken($white-normal, $darken-border-factor);
|
||||
|
||||
$border-gray-light: darken($gray-light, $darken-border-factor);
|
||||
$border-gray-normal: darken($gray-normal, $darken-border-factor);
|
||||
$border-gray-dark: darken($white-normal, $darken-border-factor);
|
||||
|
||||
|
@ -274,6 +275,7 @@ $dropdown-hover-color: #3b86ff;
|
|||
*/
|
||||
$btn-active-gray: #ececec;
|
||||
$btn-active-gray-light: e4e7ed;
|
||||
$btn-white-active: #848484;
|
||||
|
||||
/*
|
||||
* Badges
|
||||
|
|
|
@ -30,6 +30,15 @@ module IssuablesHelper
|
|||
end
|
||||
end
|
||||
|
||||
def serialize_issuable(issuable)
|
||||
case issuable
|
||||
when Issue
|
||||
IssueSerializer.new.represent(issuable).to_json
|
||||
when MergeRequest
|
||||
MergeRequestSerializer.new.represent(issuable).to_json
|
||||
end
|
||||
end
|
||||
|
||||
def template_dropdown_tag(issuable, &block)
|
||||
title = selected_template(issuable) || "Choose a template"
|
||||
options = {
|
||||
|
|
|
@ -13,6 +13,7 @@ module Issuable
|
|||
include StripAttribute
|
||||
include Awardable
|
||||
include Taskable
|
||||
include TimeTrackable
|
||||
|
||||
included do
|
||||
cache_markdown_field :title, pipeline: :single_line
|
||||
|
|
58
app/models/concerns/time_trackable.rb
Normal file
58
app/models/concerns/time_trackable.rb
Normal file
|
@ -0,0 +1,58 @@
|
|||
# == TimeTrackable concern
|
||||
#
|
||||
# Contains functionality related to objects that support time tracking.
|
||||
#
|
||||
# Used by Issue and MergeRequest.
|
||||
#
|
||||
|
||||
module TimeTrackable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
attr_reader :time_spent
|
||||
|
||||
alias_method :time_spent?, :time_spent
|
||||
|
||||
default_value_for :time_estimate, value: 0, allows_nil: false
|
||||
|
||||
has_many :timelogs, as: :trackable, dependent: :destroy
|
||||
end
|
||||
|
||||
def spend_time(seconds, user)
|
||||
return if seconds == 0
|
||||
|
||||
@time_spent = seconds
|
||||
@time_spent_user = user
|
||||
|
||||
if seconds == :reset
|
||||
reset_spent_time
|
||||
else
|
||||
add_or_subtract_spent_time
|
||||
end
|
||||
end
|
||||
|
||||
def total_time_spent
|
||||
timelogs.sum(:time_spent)
|
||||
end
|
||||
|
||||
def human_total_time_spent
|
||||
Gitlab::TimeTrackingFormatter.output(total_time_spent)
|
||||
end
|
||||
|
||||
def human_time_estimate
|
||||
Gitlab::TimeTrackingFormatter.output(time_estimate)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_spent_time
|
||||
timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user)
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
6
app/models/timelog.rb
Normal file
6
app/models/timelog.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
class Timelog < ActiveRecord::Base
|
||||
validates :time_spent, :user, presence: true
|
||||
|
||||
belongs_to :trackable, polymorphic: true
|
||||
belongs_to :user
|
||||
end
|
|
@ -13,4 +13,8 @@ class IssuableEntity < Grape::Entity
|
|||
expose :created_at
|
||||
expose :updated_at
|
||||
expose :deleted_at
|
||||
expose :time_estimate
|
||||
expose :total_time_spent
|
||||
expose :human_time_estimate
|
||||
expose :human_total_time_spent
|
||||
end
|
||||
|
|
|
@ -36,6 +36,14 @@ class IssuableBaseService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def create_time_estimate_note(issuable)
|
||||
SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
|
||||
end
|
||||
|
||||
def create_time_spent_note(issuable)
|
||||
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
|
||||
end
|
||||
|
||||
def filter_params(issuable)
|
||||
ability_name = :"admin_#{issuable.to_ability_name}"
|
||||
|
||||
|
@ -156,6 +164,7 @@ class IssuableBaseService < BaseService
|
|||
def create(issuable)
|
||||
merge_slash_commands_into_params!(issuable)
|
||||
filter_params(issuable)
|
||||
change_time_spent(issuable)
|
||||
|
||||
params.delete(:state_event)
|
||||
params[:author] ||= current_user
|
||||
|
@ -198,13 +207,14 @@ class IssuableBaseService < BaseService
|
|||
change_subscription(issuable)
|
||||
change_todo(issuable)
|
||||
filter_params(issuable)
|
||||
time_spent = change_time_spent(issuable)
|
||||
old_labels = issuable.labels.to_a
|
||||
old_mentioned_users = issuable.mentioned_users.to_a
|
||||
|
||||
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)
|
||||
|
||||
if params.present? && update_issuable(issuable, params)
|
||||
if (params.present? || time_spent) && update_issuable(issuable, params)
|
||||
# We do not touch as it will affect a update on updated_at field
|
||||
ActiveRecord::Base.no_touching do
|
||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||
|
@ -251,6 +261,12 @@ class IssuableBaseService < BaseService
|
|||
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: [])
|
||||
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
||||
|
||||
|
@ -272,6 +288,14 @@ class IssuableBaseService < BaseService
|
|||
create_task_status_note(issuable)
|
||||
end
|
||||
|
||||
if issuable.previous_changes.include?('time_estimate')
|
||||
create_time_estimate_note(issuable)
|
||||
end
|
||||
|
||||
if issuable.time_spent?
|
||||
create_time_spent_note(issuable)
|
||||
end
|
||||
|
||||
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
|
||||
end
|
||||
end
|
||||
|
|
|
@ -243,6 +243,53 @@ module SlashCommands
|
|||
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
|
||||
end
|
||||
|
||||
desc 'Set time estimate'
|
||||
params '<1w 3d 2h 14m>'
|
||||
condition do
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||
end
|
||||
command :estimate do |raw_duration|
|
||||
time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
|
||||
|
||||
if time_estimate
|
||||
@updates[:time_estimate] = time_estimate
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Add or substract spent time'
|
||||
params '<1h 30m | -1h 30m>'
|
||||
condition do
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
|
||||
end
|
||||
command :spend do |raw_duration|
|
||||
reduce_time = raw_duration.sub!(/\A-/, '')
|
||||
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
|
||||
|
||||
if time_spent
|
||||
time_spent *= -1 if reduce_time
|
||||
|
||||
@updates[:spend_time] = time_spent
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Remove time estimate'
|
||||
condition do
|
||||
issuable.persisted? &&
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||
end
|
||||
command :remove_estimate do
|
||||
@updates[:time_estimate] = 0
|
||||
end
|
||||
|
||||
desc 'Remove spent time'
|
||||
condition do
|
||||
issuable.persisted? &&
|
||||
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
|
||||
end
|
||||
command :remove_time_spent do
|
||||
@updates[:spend_time] = :reset
|
||||
end
|
||||
|
||||
# This is a dummy command, so that it appears in the autocomplete commands
|
||||
desc 'CC'
|
||||
params '@user'
|
||||
|
|
|
@ -109,6 +109,57 @@ module SystemNoteService
|
|||
create_note(noteable: noteable, project: project, author: author, note: body)
|
||||
end
|
||||
|
||||
# Called when the estimated time of a Noteable is changed
|
||||
#
|
||||
# noteable - Noteable object
|
||||
# project - Project owning noteable
|
||||
# author - User performing the change
|
||||
# time_estimate - Estimated time
|
||||
#
|
||||
# Example Note text:
|
||||
#
|
||||
# "Changed estimate of this issue to 3d 5h"
|
||||
#
|
||||
# Returns the created Note object
|
||||
|
||||
def change_time_estimate(noteable, project, author)
|
||||
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
|
||||
body = if noteable.time_estimate == 0
|
||||
"Removed time estimate on this #{noteable.human_class_name}"
|
||||
else
|
||||
"Changed time estimate of this #{noteable.human_class_name} to #{parsed_time}"
|
||||
end
|
||||
|
||||
create_note(noteable: noteable, project: project, author: author, note: body)
|
||||
end
|
||||
|
||||
# Called when the spent time of a Noteable is changed
|
||||
#
|
||||
# noteable - Noteable object
|
||||
# project - Project owning noteable
|
||||
# author - User performing the change
|
||||
# time_spent - Spent time
|
||||
#
|
||||
# Example Note text:
|
||||
#
|
||||
# "Added 2h 30m of time spent on this issue"
|
||||
#
|
||||
# Returns the created Note object
|
||||
|
||||
def change_time_spent(noteable, project, author)
|
||||
time_spent = noteable.time_spent
|
||||
|
||||
if time_spent == :reset
|
||||
body = "Removed time spent on this #{noteable.human_class_name}"
|
||||
else
|
||||
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
|
||||
action = time_spent > 0 ? 'Added' : 'Subtracted'
|
||||
body = "#{action} #{parsed_time} of time spent on this #{noteable.human_class_name}"
|
||||
end
|
||||
|
||||
create_note(noteable: noteable, project: project, author: author, note: body)
|
||||
end
|
||||
|
||||
# Called when the status of a Noteable is changed
|
||||
#
|
||||
# noteable - Noteable object
|
||||
|
|
30
db/migrate/20161223034433_add_time_estimate_to_issuables.rb
Normal file
30
db/migrate/20161223034433_add_time_estimate_to_issuables.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class AddTimeEstimateToIssuables < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
# When a migration requires downtime you **must** uncomment the following
|
||||
# constant and define a short and easy to understand explanation as to why the
|
||||
# migration requires downtime.
|
||||
# DOWNTIME_REASON = ''
|
||||
|
||||
# When using the methods "add_concurrent_index" or "add_column_with_default"
|
||||
# you must disable the use of transactions as these methods can not run in an
|
||||
# existing transaction. When using "add_concurrent_index" make sure that this
|
||||
# method is the _only_ method called in the migration, any other changes
|
||||
# should go in a separate migration. This ensures that upon failure _only_ the
|
||||
# index creation fails and can be retried or reverted easily.
|
||||
#
|
||||
# To disable transactions uncomment the following line and remove these
|
||||
# comments:
|
||||
# disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :issues, :time_estimate, :integer
|
||||
add_column :merge_requests, :time_estimate, :integer
|
||||
end
|
||||
end
|
38
db/migrate/20161223034646_create_timelogs.rb
Normal file
38
db/migrate/20161223034646_create_timelogs.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class CreateTimelogs < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
# When a migration requires downtime you **must** uncomment the following
|
||||
# constant and define a short and easy to understand explanation as to why the
|
||||
# migration requires downtime.
|
||||
# DOWNTIME_REASON = ''
|
||||
|
||||
# When using the methods "add_concurrent_index" or "add_column_with_default"
|
||||
# you must disable the use of transactions as these methods can not run in an
|
||||
# existing transaction. When using "add_concurrent_index" make sure that this
|
||||
# method is the _only_ method called in the migration, any other changes
|
||||
# should go in a separate migration. This ensures that upon failure _only_ the
|
||||
# index creation fails and can be retried or reverted easily.
|
||||
#
|
||||
# To disable transactions uncomment the following line and remove these
|
||||
# comments:
|
||||
# disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
create_table :timelogs do |t|
|
||||
t.integer :time_spent, null: false
|
||||
t.references :trackable, polymorphic: true
|
||||
t.references :user
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
add_index :timelogs, [:trackable_type, :trackable_id]
|
||||
add_index :timelogs, :user_id
|
||||
end
|
||||
end
|
14
db/schema.rb
14
db/schema.rb
|
@ -506,6 +506,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
|
|||
t.integer "lock_version"
|
||||
t.text "title_html"
|
||||
t.text "description_html"
|
||||
t.integer "time_estimate"
|
||||
end
|
||||
|
||||
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
|
||||
|
@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
|
|||
t.integer "lock_version"
|
||||
t.text "title_html"
|
||||
t.text "description_html"
|
||||
t.integer "time_estimate"
|
||||
end
|
||||
|
||||
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
|
||||
|
@ -1128,6 +1130,18 @@ ActiveRecord::Schema.define(version: 20170106172224) do
|
|||
|
||||
add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree
|
||||
|
||||
create_table "timelogs", force: :cascade do |t|
|
||||
t.integer "time_spent", null: false
|
||||
t.integer "trackable_id"
|
||||
t.string "trackable_type"
|
||||
t.integer "user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
add_index "timelogs", ["trackable_type", "trackable_id"], name: "index_timelogs_on_trackable_type_and_trackable_id", using: :btree
|
||||
add_index "timelogs", ["user_id"], name: "index_timelogs_on_user_id", using: :btree
|
||||
|
||||
create_table "todos", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "project_id", null: false
|
||||
|
|
|
@ -29,3 +29,7 @@ do.
|
|||
| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date |
|
||||
| `/remove_due_date` | Remove due date |
|
||||
| `/wip` | Toggle the Work In Progress status |
|
||||
| <code>/estimate <1w 3d 2h 14m></code> | Set time estimate |
|
||||
| `/remove_estimate` | Remove estimated time |
|
||||
| <code>/spend <1h 30m | -1h 5m></code> | Add or substract spent time |
|
||||
| `/remove_time_spent` | Remove time spent |
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
- [Slash commands](../user/project/slash_commands.md)
|
||||
- [Sharing a project with a group](share_with_group.md)
|
||||
- [Share projects with other groups](share_projects_with_other_groups.md)
|
||||
- [Time tracking](time_tracking.md)
|
||||
- [Web Editor](../user/project/repository/web_editor.md)
|
||||
- [Releases](releases.md)
|
||||
- [Milestones](milestones.md)
|
||||
|
|
BIN
doc/workflow/time-tracking/time-tracking-example.png
Normal file
BIN
doc/workflow/time-tracking/time-tracking-example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
doc/workflow/time-tracking/time-tracking-sidebar.png
Normal file
BIN
doc/workflow/time-tracking/time-tracking-sidebar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
76
doc/workflow/time_tracking.md
Normal file
76
doc/workflow/time_tracking.md
Normal file
|
@ -0,0 +1,76 @@
|
|||
# Time Tracking
|
||||
|
||||
> Introduced in GitLab 8.14.
|
||||
|
||||
Time Tracking lets teams stack their project estimates against their time spent.
|
||||
|
||||
Other interesting links:
|
||||
|
||||
- [Time Tracking landing page on about.gitlab.com][landing]
|
||||
|
||||
## Overview
|
||||
|
||||
Time Tracking lets you:
|
||||
* record the time spent working on an issue or a merge request,
|
||||
* add an estimate of the amount of time needed to complete an issue or a merge
|
||||
request.
|
||||
|
||||
You don't have to indicate an estimate to enter the time spent, and vice versa.
|
||||
|
||||
Data about time tracking is shown on the issue/merge request sidebar, as shown
|
||||
below.
|
||||
|
||||
![Time tracking in the sidebar](time-tracking/time-tracking-sidebar.png)
|
||||
|
||||
## How to enter data
|
||||
|
||||
Time Tracking uses two [slash commands] that GitLab introduced with this new
|
||||
feature: `/spend` and `/estimate`.
|
||||
|
||||
Slash commands can be used in the body of an issue or a merge request, but also
|
||||
in a comment in both an issue or a merge request.
|
||||
|
||||
Below is an example of how you can use those new slash commands inside a comment.
|
||||
|
||||
![Time tracking example in a comment](time-tracking/time-tracking-example.png)
|
||||
|
||||
Adding time entries (time spent or estimates) is limited to project members.
|
||||
|
||||
### Estimates
|
||||
|
||||
To enter an estimate, write `/estimate`, followed by the time. For example, if
|
||||
you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write
|
||||
`/estimate 3d 5h 10m`.
|
||||
|
||||
Every time you enter a new time estimate, any previous time estimates will be
|
||||
overridden by this new value. There should only be one valid estimate in an
|
||||
issue or a merge request.
|
||||
|
||||
To remove an estimation entirely, use `/remove_estimation`.
|
||||
|
||||
### Time spent
|
||||
|
||||
To enter a time spent, use `/spend 3d 5h 10m`.
|
||||
|
||||
Every new time spent entry will be added to the current total time spent for the
|
||||
issue or the merge request.
|
||||
|
||||
You can remove time by entering a negative amount: `/spend -3d` will remove 3
|
||||
days from the total time spent. You can't go below 0 minutes of time spent,
|
||||
so GitLab will automatically reset the time spent if you remove a larger amount
|
||||
of time compared to the time that was entered already.
|
||||
|
||||
To remove all the time spent at once, use `/remove_time_spent`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The following time units are available:
|
||||
* weeks (w)
|
||||
* days (d)
|
||||
* hours (h)
|
||||
* minutes (m)
|
||||
|
||||
Default conversion rates are 1w = 5d and 1d = 8h.
|
||||
|
||||
[landing]: https://about.gitlab.com/features/time-tracking
|
||||
[slash-commands]: ../user/project/slash_commands.md
|
|
@ -6,6 +6,7 @@ project_tree:
|
|||
- :events
|
||||
- issues:
|
||||
- :events
|
||||
- :timelogs
|
||||
- notes:
|
||||
- :author
|
||||
- :events
|
||||
|
@ -27,6 +28,7 @@ project_tree:
|
|||
- :events
|
||||
- :merge_request_diff
|
||||
- :events
|
||||
- :timelogs
|
||||
- label_links:
|
||||
- label:
|
||||
:priorities
|
||||
|
|
30
lib/gitlab/time_tracking_formatter.rb
Normal file
30
lib/gitlab/time_tracking_formatter.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
module Gitlab
|
||||
module TimeTrackingFormatter
|
||||
extend self
|
||||
|
||||
def parse(string)
|
||||
with_custom_config do
|
||||
ChronicDuration.parse(string, default_unit: 'hours') rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
def output(seconds)
|
||||
with_custom_config do
|
||||
ChronicDuration.output(seconds, format: :short, limit_to_hours: false, weeks: true) rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
def with_custom_config
|
||||
# We may want to configure it through project settings in a future version.
|
||||
ChronicDuration.hours_per_day = 8
|
||||
ChronicDuration.days_per_week = 5
|
||||
|
||||
result = yield
|
||||
|
||||
ChronicDuration.hours_per_day = 24
|
||||
ChronicDuration.days_per_week = 7
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
|
@ -326,6 +326,20 @@ describe Projects::IssuesController do
|
|||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
def post_new_issue(attrs = {})
|
||||
sign_in(user)
|
||||
project = create(:empty_project, :public)
|
||||
project.team << [user, :developer]
|
||||
|
||||
post :create, {
|
||||
namespace_id: project.namespace.to_param,
|
||||
project_id: project.to_param,
|
||||
issue: { title: 'Title', description: 'Description' }.merge(attrs)
|
||||
}
|
||||
|
||||
project.issues.first
|
||||
end
|
||||
|
||||
context 'resolving discussions in MergeRequest' do
|
||||
let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
|
||||
let(:merge_request) { discussion.noteable }
|
||||
|
@ -369,13 +383,7 @@ describe Projects::IssuesController do
|
|||
end
|
||||
|
||||
def post_spam_issue
|
||||
sign_in(user)
|
||||
spam_project = create(:empty_project, :public)
|
||||
post :create, {
|
||||
namespace_id: spam_project.namespace.to_param,
|
||||
project_id: spam_project.to_param,
|
||||
issue: { title: 'Spam Title', description: 'Spam lives here' }
|
||||
}
|
||||
post_new_issue(title: 'Spam Title', description: 'Spam lives here')
|
||||
end
|
||||
|
||||
it 'rejects an issue recognized as spam' do
|
||||
|
@ -396,20 +404,28 @@ describe Projects::IssuesController do
|
|||
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
|
||||
end
|
||||
|
||||
def post_new_issue
|
||||
sign_in(user)
|
||||
project = create(:empty_project, :public)
|
||||
post :create, {
|
||||
namespace_id: project.namespace.to_param,
|
||||
project_id: project.to_param,
|
||||
issue: { title: 'Title', description: 'Description' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a user agent detail' do
|
||||
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when description has slash commands' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'can add spent time' do
|
||||
issue = post_new_issue(description: '/spend 1h')
|
||||
|
||||
expect(issue.total_time_spent).to eq(3600)
|
||||
end
|
||||
|
||||
it 'can set the time estimate' do
|
||||
issue = post_new_issue(description: '/estimate 2h')
|
||||
|
||||
expect(issue.time_estimate).to eq(7200)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #mark_as_spam' do
|
||||
|
|
9
spec/factories/timelogs.rb
Normal file
9
spec/factories/timelogs.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Read about factories at https://github.com/thoughtbot/factory_girl
|
||||
|
||||
FactoryGirl.define do
|
||||
factory :timelog do
|
||||
time_spent 3600
|
||||
user
|
||||
association :trackable, factory: :issue
|
||||
end
|
||||
end
|
|
@ -126,6 +126,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'Issuable time tracking' do
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
|
||||
before do
|
||||
project.team << [user, :developer]
|
||||
end
|
||||
|
||||
context 'Issue' do
|
||||
before do
|
||||
visit namespace_project_issue_path(project.namespace, project, issue)
|
||||
end
|
||||
|
||||
it_behaves_like 'issuable time tracker'
|
||||
end
|
||||
|
||||
context 'Merge Request' do
|
||||
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||
|
||||
before do
|
||||
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
|
||||
end
|
||||
|
||||
it_behaves_like 'issuable time tracker'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'toggling the WIP prefix from the title from note' do
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ issues:
|
|||
- events
|
||||
- merge_requests_closing_issues
|
||||
- metrics
|
||||
- timelogs
|
||||
events:
|
||||
- author
|
||||
- project
|
||||
|
@ -77,6 +78,7 @@ merge_requests:
|
|||
- events
|
||||
- merge_requests_closing_issues
|
||||
- metrics
|
||||
- timelogs
|
||||
merge_request_diff:
|
||||
- merge_request
|
||||
pipelines:
|
||||
|
@ -198,3 +200,6 @@ award_emoji:
|
|||
- user
|
||||
priorities:
|
||||
- label
|
||||
timelogs:
|
||||
- trackable
|
||||
- user
|
||||
|
|
|
@ -20,6 +20,7 @@ Issue:
|
|||
- lock_version
|
||||
- milestone_id
|
||||
- weight
|
||||
- time_estimate
|
||||
Event:
|
||||
- id
|
||||
- target_type
|
||||
|
@ -150,6 +151,7 @@ MergeRequest:
|
|||
- milestone_id
|
||||
- approvals_before_merge
|
||||
- rebase_commit_sha
|
||||
- time_estimate
|
||||
MergeRequestDiff:
|
||||
- id
|
||||
- state
|
||||
|
@ -344,3 +346,11 @@ LabelPriority:
|
|||
- priority
|
||||
- created_at
|
||||
- updated_at
|
||||
Timelog:
|
||||
- id
|
||||
- time_spent
|
||||
- trackable_id
|
||||
- trackable_type
|
||||
- user_id
|
||||
- created_at
|
||||
- updated_at
|
||||
|
|
|
@ -408,4 +408,42 @@ describe Issue, "Issuable" do
|
|||
expect(issue.assignee_or_author?(user)).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#spend_time' do
|
||||
let(:user) { create(:user) }
|
||||
let(:issue) { create(:issue) }
|
||||
|
||||
def spend_time(seconds)
|
||||
issue.spend_time(seconds, user)
|
||||
issue.save!
|
||||
end
|
||||
|
||||
context 'adding time' do
|
||||
it 'should update the total time spent' do
|
||||
spend_time(1800)
|
||||
|
||||
expect(issue.total_time_spent).to eq(1800)
|
||||
end
|
||||
end
|
||||
|
||||
context 'substracting time' do
|
||||
before do
|
||||
spend_time(1800)
|
||||
end
|
||||
|
||||
it 'should update the total time spent' do
|
||||
spend_time(-900)
|
||||
|
||||
expect(issue.total_time_spent).to eq(900)
|
||||
end
|
||||
|
||||
context 'when time to substract exceeds the total time spent' do
|
||||
it 'should not alter the total time spent' do
|
||||
spend_time(-3600)
|
||||
|
||||
expect(issue.total_time_spent).to eq(1800)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
10
spec/models/timelog_spec.rb
Normal file
10
spec/models/timelog_spec.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Timelog, type: :model do
|
||||
subject { build(:timelog) }
|
||||
|
||||
it { is_expected.to be_valid }
|
||||
|
||||
it { is_expected.to validate_presence_of(:time_spent) }
|
||||
it { is_expected.to validate_presence_of(:user) }
|
||||
end
|
|
@ -86,6 +86,18 @@ describe Notes::SlashCommandsService, services: true do
|
|||
expect(note.noteable).to be_open
|
||||
end
|
||||
end
|
||||
|
||||
describe '/spend' do
|
||||
let(:note_text) { '/spend 1h' }
|
||||
|
||||
it 'updates the spent time on the noteable' do
|
||||
content, command_params = service.extract_commands(note)
|
||||
service.execute(command_params, note)
|
||||
|
||||
expect(content).to eq ''
|
||||
expect(note.noteable.time_spent).to eq(3600)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'note with command & text' do
|
||||
|
|
|
@ -210,6 +210,46 @@ describe SlashCommands::InterpretService, services: true do
|
|||
end
|
||||
end
|
||||
|
||||
shared_examples 'estimate command' do
|
||||
it 'populates time_estimate: 3600 if content contains /estimate 1h' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
||||
expect(updates).to eq(time_estimate: 3600)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'spend command' do
|
||||
it 'populates spend_time: 3600 if content contains /spend 1h' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
||||
expect(updates).to eq(spend_time: 3600)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'spend command with negative time' do
|
||||
it 'populates spend_time: -1800 if content contains /spend -30m' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
||||
expect(updates).to eq(spend_time: -1800)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'remove_estimate command' do
|
||||
it 'populates time_estimate: 0 if content contains /remove_estimate' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
||||
expect(updates).to eq(time_estimate: 0)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'remove_time_spent command' do
|
||||
it 'populates spend_time: :reset if content contains /remove_time_spent' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
||||
expect(updates).to eq(spend_time: :reset)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'empty command' do
|
||||
it 'populates {} if content contains an unsupported command' do
|
||||
_, updates = service.execute(content, issuable)
|
||||
|
@ -451,6 +491,51 @@ describe SlashCommands::InterpretService, services: true do
|
|||
let(:issuable) { merge_request }
|
||||
end
|
||||
|
||||
it_behaves_like 'estimate command' do
|
||||
let(:content) { '/estimate 1h' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'empty command' do
|
||||
let(:content) { '/estimate' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'empty command' do
|
||||
let(:content) { '/estimate abc' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'spend command' do
|
||||
let(:content) { '/spend 1h' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'spend command with negative time' do
|
||||
let(:content) { '/spend -30m' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'empty command' do
|
||||
let(:content) { '/spend' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'empty command' do
|
||||
let(:content) { '/spend abc' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'remove_estimate command' do
|
||||
let(:content) { '/remove_estimate' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
it_behaves_like 'remove_time_spent command' do
|
||||
let(:content) { '/remove_time_spent' }
|
||||
let(:issuable) { issue }
|
||||
end
|
||||
|
||||
context 'when current_user cannot :admin_issue' do
|
||||
let(:visitor) { create(:user) }
|
||||
let(:issue) { create(:issue, project: project, author: visitor) }
|
||||
|
|
|
@ -740,4 +740,69 @@ describe SystemNoteService, services: true do
|
|||
expect(note.note).to include(issue.to_reference)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.change_time_estimate' do
|
||||
subject { described_class.change_time_estimate(noteable, project, author) }
|
||||
|
||||
it_behaves_like 'a system note'
|
||||
|
||||
context 'with a time estimate' do
|
||||
it 'sets the note text' do
|
||||
noteable.update_attribute(:time_estimate, 277200)
|
||||
|
||||
expect(subject.note).to eq "Changed time estimate of this issue to 1w 4d 5h"
|
||||
end
|
||||
end
|
||||
|
||||
context 'without a time estimate' do
|
||||
it 'sets the note text' do
|
||||
expect(subject.note).to eq "Removed time estimate on this issue"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.change_time_spent' do
|
||||
# We need a custom noteable in order to the shared examples to be green.
|
||||
let(:noteable) do
|
||||
mr = create(:merge_request, source_project: project)
|
||||
mr.spend_time(1, author)
|
||||
mr.save!
|
||||
mr
|
||||
end
|
||||
|
||||
subject do
|
||||
described_class.change_time_spent(noteable, project, author)
|
||||
end
|
||||
|
||||
it_behaves_like 'a system note'
|
||||
|
||||
context 'when time was added' do
|
||||
it 'sets the note text' do
|
||||
spend_time!(277200)
|
||||
|
||||
expect(subject.note).to eq "Added 1w 4d 5h of time spent on this merge request"
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time was subtracted' do
|
||||
it 'sets the note text' do
|
||||
spend_time!(-277200)
|
||||
|
||||
expect(subject.note).to eq "Subtracted 1w 4d 5h of time spent on this merge request"
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time was removed' do
|
||||
it 'sets the note text' do
|
||||
spend_time!(:reset)
|
||||
|
||||
expect(subject.note).to eq "Removed time spent on this merge request"
|
||||
end
|
||||
end
|
||||
|
||||
def spend_time!(seconds)
|
||||
noteable.spend_time(seconds, author)
|
||||
noteable.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue