diff --git a/app/assets/javascripts/lib/vue_resource.js.es6 b/app/assets/javascripts/lib/vue_resource.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..eff1dcabfa2d5b381b3c519711c9cc90f704f84a --- /dev/null +++ b/app/assets/javascripts/lib/vue_resource.js.es6 @@ -0,0 +1,2 @@ +//= require vue +//= require vue-resource diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 4baf6ee781afed05e3af2c59dce63b680a4b89b8..ee1c95fd373b9012c07697b3c73cee6e06641c31 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -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 diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 1c213983a5bbcc9a510bb8c79487300ee4b1d979..e5bb8b93e7693e5d096949f66d855a6a0a5ecbd5 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -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 = { diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 5e63825bf99f752eda2105e24325f5528d59ef9d..3517969eabc65b45ae612944c8e525df1ffffbc9 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -13,6 +13,7 @@ module Issuable include StripAttribute include Awardable include Taskable + include TimeTrackable included do cache_markdown_field :title, pipeline: :single_line diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb new file mode 100644 index 0000000000000000000000000000000000000000..6fa2af4e4e6da2b863089edee20f588e3293ac3e --- /dev/null +++ b/app/models/concerns/time_trackable.rb @@ -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 diff --git a/app/models/timelog.rb b/app/models/timelog.rb new file mode 100644 index 0000000000000000000000000000000000000000..f768c4e3da5da4531063367de6fea5ea3ebab0d3 --- /dev/null +++ b/app/models/timelog.rb @@ -0,0 +1,6 @@ +class Timelog < ActiveRecord::Base + validates :time_spent, :user, presence: true + + belongs_to :trackable, polymorphic: true + belongs_to :user +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 17c9160cb197b5f4884ad2827075a08d48554d44..29aecb50849fce40bd630b01c80763e8a7ce8cbe 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -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 diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 4ce5fd993d90290229c2544aca5af24abc8da54e..7491c256b998ed5f77a97b0540cb18612ed3858b 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -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 diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index d75c5b1800e5f3049495fba08e87dad6b1ec27fc..ea00415ae1fe5127a007231b98212ec44cba15f3 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -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' diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 7613ecd5021cd4d7dd447f53c43ea80d5c0f622d..5ca2551ee61680821b317b311fb58164cfb647a4 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -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 diff --git a/db/migrate/20161223034433_add_time_estimate_to_issuables.rb b/db/migrate/20161223034433_add_time_estimate_to_issuables.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d89756a9bcd6a3b7ca5b21f215faf1ae22f9b1d --- /dev/null +++ b/db/migrate/20161223034433_add_time_estimate_to_issuables.rb @@ -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 diff --git a/db/migrate/20161223034646_create_timelogs.rb b/db/migrate/20161223034646_create_timelogs.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3353a67eec086123bb2b991dc5ee75d77e7b08f --- /dev/null +++ b/db/migrate/20161223034646_create_timelogs.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index c58a886b0fa4ab019daab74353e77a2053d07fcd..7815392c1c3b84fb785ac5b182ba66e2ede806a8 100644 --- a/db/schema.rb +++ b/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 diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md index 5f6a6c6503e4e72adab1bcf163f987892476db32..87c9756ea5dac489f6a68658ac9dfcfe03f40b4d 100644 --- a/doc/user/project/slash_commands.md +++ b/doc/user/project/slash_commands.md @@ -29,3 +29,7 @@ do. | /due <in 2 days | this Friday | December 31st> | Set due date | | `/remove_due_date` | Remove due date | | `/wip` | Toggle the Work In Progress status | +| /estimate <1w 3d 2h 14m> | Set time estimate | +| `/remove_estimate` | Remove estimated time | +| /spend <1h 30m | -1h 5m> | Add or substract spent time | +| `/remove_time_spent` | Remove time spent | diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 59a806de210f14d3985d23fc13522c4eb037b48e..b317bd79dedeffe2b0a34f1aeed320eb4074f258 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -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) diff --git a/doc/workflow/time-tracking/time-tracking-example.png b/doc/workflow/time-tracking/time-tracking-example.png new file mode 100644 index 0000000000000000000000000000000000000000..bbcabb602d67655043daa1b55bd37674642ba91a Binary files /dev/null and b/doc/workflow/time-tracking/time-tracking-example.png differ diff --git a/doc/workflow/time-tracking/time-tracking-sidebar.png b/doc/workflow/time-tracking/time-tracking-sidebar.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ff5571f95e7cd0d3bf284227727f3e2255e7ae Binary files /dev/null and b/doc/workflow/time-tracking/time-tracking-sidebar.png differ diff --git a/doc/workflow/time_tracking.md b/doc/workflow/time_tracking.md new file mode 100644 index 0000000000000000000000000000000000000000..3b3103110d3c9a416275bf5292a1304ac366ade9 --- /dev/null +++ b/doc/workflow/time_tracking.md @@ -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 diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index e6ecd11860999cdef08ad2c2d9b217419fa47249..08ad3274b3803f4155a8df3efa72e14deb01511f 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -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 diff --git a/lib/gitlab/time_tracking_formatter.rb b/lib/gitlab/time_tracking_formatter.rb new file mode 100644 index 0000000000000000000000000000000000000000..d09063c6c8f97a8568bacfb11c3f9b3c6ea2f822 --- /dev/null +++ b/lib/gitlab/time_tracking_formatter.rb @@ -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 diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index e2321f2034b50eff8605eec211b83a96f7fb487b..b5987a83df0affcea451c4d78e8b718d97eda9af 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -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,18 +404,26 @@ describe Projects::IssuesController do request.env['action_dispatch.remote_ip'] = '127.0.0.1' end - def post_new_issue + 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) - 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) + 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 diff --git a/spec/factories/timelogs.rb b/spec/factories/timelogs.rb new file mode 100644 index 0000000000000000000000000000000000000000..12fc4ec448634f523fd2be60435b1345d88d0be7 --- /dev/null +++ b/spec/factories/timelogs.rb @@ -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 diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index f2d4aadf5406414e470763d07cb4b53113b6a564..0a9cd11ad6e524eaae8d15a67217cba40dd4503f 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -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) } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index ceed9c942c11bddbedc517e0ea0bd86f512b7184..7fb6829f582f2514459b6644c05a0af35ade83a3 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -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 diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index d88a141b458ed5117bfa04d257d78aa23e8256ec..493bc2db21af108def3c04b6bb3fbac414a990e8 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -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 diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 1078c959419da528516b9c28b4c864a5e689436c..344906c581b57da060c33455c82ed4e2a0d3cd36 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -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 diff --git a/spec/models/timelog_spec.rb b/spec/models/timelog_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f08935b64254c7154702f9ca767c0ba2f913e1c3 --- /dev/null +++ b/spec/models/timelog_spec.rb @@ -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 diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 960b5cd5e6fca8ca6af7e17cf9d95af9e102f132..1a64c8bbf00df05a396be79b356f8b73afa3e3ef 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -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 diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index becf627a4f582cf20cd0c735fb48246025a9dd67..e1358acd7c14e345e9794c5b2ee03c3c6fb1f529 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -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) } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 0e8adb687212bd1149c986788b0e079c584dc4ad..e85545f46dc4d684c085d642fbe30a8252ca995e 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -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